diff --git a/CHANGELOG.md b/CHANGELOG.md index 946d58d..9a3616f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ --- +## Version 1.4.3 + +Release date: unreleased + +### Changes + +- `FIXED`: **Stale PTZ buttons** — buttons for cameras that no longer report PTZ (e.g. after ticking *Disable PTZ* in SecuritySpy) are now removed when the integration reloads, instead of lingering as unavailable. Offline cameras keep theirs. + ## Version 1.4.2 Release date: `2026-09-21` @@ -18,6 +26,7 @@ Release date: `2026-09-21` Thanks to @jpettitt for the fix and the test scaffold, and to @photonclock for the review that turned it into a per-camera choice. + ## Version 1.3.0 Release date: `2026-03-07` diff --git a/README.md b/README.md index 0412d84..8092fcb 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ This integration provides the following entity types: **Note on snapshot aspect ratio:** by default snapshots are stretched to fill the dashboard card, which is what this integration has always done. For a camera you'd rather see at its true shape — a 4:3 or portrait camera in a 16:9 card, say — turn on that camera's *Fit Snapshots* switch (under Configuration on the device page). Wide dual-sensor cameras usually read better left as they are, filling the card rather than letterboxed into a strip. +**Note on PTZ buttons:** SecuritySpy reports PTZ capability from the camera's driver profile, so fixed Reolink, Dahua or ONVIF cameras can get PTZ buttons. For such a camera, tick *Disable PTZ* in SecuritySpy under Settings > Cameras > Device (advanced options), then reload the integration; its buttons are removed automatically. + ## Prerequisites 1. **Enable the Web Server** in SecuritySpy: Settings > Web. Note the port number (default 8000). SSL is now supported. diff --git a/custom_components/securityspy/button.py b/custom_components/securityspy/button.py index e792287..b378908 100644 --- a/custom_components/securityspy/button.py +++ b/custom_components/securityspy/button.py @@ -2,13 +2,16 @@ from __future__ import annotations import logging +from collections.abc import Iterable from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, slugify_camera_name from .entity import SecuritySpyEntity _LOGGER = logging.getLogger(__name__) @@ -68,11 +71,67 @@ async def async_setup_entry( ) ) + _async_remove_stale_buttons( + hass, entry, server_info, secspy_data, {button.unique_id for button in sensors} + ) async_add_entities(sensors) return True +def stale_button_entity_ids( + registered: Iterable[tuple[str, str]], + expected_unique_ids: set[str], + online_camera_suffixes: set[str], +) -> list[str]: + """Pick registered buttons that an online camera no longer offers. + + Only cameras SecuritySpy currently reports online are candidates: an offline + or vanished camera keeps its buttons, so a transient outage can't delete + them along with the user's customisations. + """ + return [ + entity_id + for entity_id, unique_id in registered + if unique_id not in expected_unique_ids + and any(unique_id.endswith(suffix) for suffix in online_camera_suffixes) + ] + + +@callback +def _async_remove_stale_buttons( + hass: HomeAssistant, + entry: ConfigEntry, + server_info, + secspy_data, + expected_unique_ids: set[str], +) -> None: + """Remove registry entries for PTZ buttons that are no longer created. + + SecuritySpy reports ptzcapabilities from the camera's driver profile, so a + fixed camera claims PTZ until "Disable PTZ" is ticked in its Device settings. + After that the buttons stop being created here but would otherwise linger in + the registry as unavailable. + """ + server_id = server_info["server_id"] + online_suffixes = { + f"_{server_id}_{slugify_camera_name(device_data['name'])}" + for device_data in secspy_data.data.values() + if device_data["online"] + } + registry = er.async_get(hass) + registered = [ + (entity_entry.entity_id, entity_entry.unique_id) + for entity_entry in er.async_entries_for_config_entry(registry, entry.entry_id) + if entity_entry.domain == Platform.BUTTON + ] + for entity_id in stale_button_entity_ids( + registered, expected_unique_ids, online_suffixes + ): + _LOGGER.debug("Removing PTZ button %s: camera no longer reports it", entity_id) + registry.async_remove(entity_id) + + class SecuritySpyButtonEntity(SecuritySpyEntity, ButtonEntity): """A SecuritySpy Button entity.""" diff --git a/tests/test_ptz_button_cleanup.py b/tests/test_ptz_button_cleanup.py new file mode 100644 index 0000000..9558e69 --- /dev/null +++ b/tests/test_ptz_button_cleanup.py @@ -0,0 +1,46 @@ +"""PTZ buttons a camera no longer offers are removed, but only for online cameras.""" +from custom_components.securityspy.button import stale_button_entity_ids + +SERVER = "W6vI6knoyqB8KJINBLRy" + + +def _uid(name: str, slug: str) -> str: + return f"{name}_{SERVER}_{slug}" + + +REGISTERED = [ + ("button.garage_left", _uid("Left", "garage")), + ("button.garage_zoom_in", _uid("Zoom In", "garage")), + ("button.garden_left", _uid("Left", "garden")), + ("button.attic_left", _uid("Left", "attic")), +] + + +def test_removes_buttons_an_online_camera_no_longer_reports(): + """Garage dropped to ptzcapabilities 0: its buttons go, Garden's expected one stays.""" + expected = {_uid("Left", "garden")} + online = {f"_{SERVER}_garage", f"_{SERVER}_garden"} + assert stale_button_entity_ids(REGISTERED, expected, online) == [ + "button.garage_left", + "button.garage_zoom_in", + ] + + +def test_keeps_buttons_of_offline_or_vanished_cameras(): + """Attic is offline or gone, so its button is untouched even though unexpected.""" + online = {f"_{SERVER}_garden"} + assert stale_button_entity_ids(REGISTERED, set(), online) == ["button.garden_left"] + + +def test_no_expected_buttons_clears_every_online_camera(): + """No camera reports PTZ any more: every online camera's buttons are removed.""" + online = {f"_{SERVER}_garage", f"_{SERVER}_garden", f"_{SERVER}_attic"} + assert stale_button_entity_ids(REGISTERED, set(), online) == [ + entity_id for entity_id, _ in REGISTERED + ] + + +def test_camera_suffix_needs_the_full_slug(): + """A slug that merely ends like another's ("yard" vs "backyard") does not match.""" + registered = [("button.backyard_left", _uid("Left", "backyard"))] + assert stale_button_entity_ids(registered, set(), {f"_{SERVER}_yard"}) == []