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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 61 additions & 2 deletions custom_components/securityspy/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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."""

Expand Down
46 changes: 46 additions & 0 deletions tests/test_ptz_button_cleanup.py
Original file line number Diff line number Diff line change
@@ -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"}) == []
Loading