From 8aef83df3ce8f979b914cc0e93368641714252ed Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Thu, 17 Sep 2026 08:18:38 -0700 Subject: [PATCH] Remove PTZ buttons for cameras that no longer report PTZ SecuritySpy reports ptzcapabilities from the driver profile, so fixed cameras claim PTZ until "Disable PTZ" is ticked in their Device settings. Once it is, the buttons stopped being created but lingered in the registry as unavailable. Offline or vanished cameras keep theirs so an outage can't delete them. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 9 ++++ README.md | 2 + custom_components/securityspy/button.py | 63 ++++++++++++++++++++++++- tests/test_ptz_button_cleanup.py | 46 ++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 tests/test_ptz_button_cleanup.py 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"}) == []