From fde02171a10e7cee7ce45ee6c8575836acd7c86f Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Wed, 8 Oct 2025 09:31:29 +0300 Subject: [PATCH 01/42] Refactor config handling in async_setup function - Import `get_config` from `.config_helpers`. - Update configuration handling to use schema validation. - Add debug logging for configuration details. --- custom_components/pppp_camera/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/pppp_camera/__init__.py b/custom_components/pppp_camera/__init__.py index d2efd0e..ee96c2b 100644 --- a/custom_components/pppp_camera/__init__.py +++ b/custom_components/pppp_camera/__init__.py @@ -18,8 +18,10 @@ from .camera import PPPPCamera from .discovery import async_start_discovery +from .config_helpers import get_config from .const import ( DOMAIN, + LOGGER, PLATFORMS, CONF_DEFAULTS, CONF_IP, @@ -89,9 +91,9 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: hass.data[DOMAIN] = {} # load optional global registry config - if DOMAIN in config: - conf = config[DOMAIN] - hass.data[DOMAIN]["config"] = conf + cfg = config if DOMAIN in config else CONFIG_SCHEMA({DOMAIN: {}}) + hass.data[DOMAIN]["config"] = cfg[DOMAIN] + LOGGER.debug("Config: %s", get_config(hass)) await async_start_discovery(hass) From 5fa24cd07f9d06c8a532bb650c23026effd78f78 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:07:45 +0300 Subject: [PATCH 02/42] Keep camera session warm instead of sleeping before disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensure_connected() opened a fresh P2P session per operation and tore it down immediately afterwards, racing the Close packet ahead of fire-and- forget binary commands (PTZ, lights) so they often never executed (devbis/pppp_camera#5). The previous workaround — await asyncio.sleep(1) before closing — only made the race less likely while adding a full second of latency and a discovery+handshake to every command. Replace it with reference-counted, lock-guarded lifecycle that keeps the session open for a configurable idle window after the last operation: - connect()/close() now run under an asyncio.Lock, fixing a concurrent-open race where two callers could each start a session (one leaking). - When the refcount hits zero, teardown is deferred via an idle task; a command arriving within the window reuses the live session. The task re-checks the refcount under the lock before closing, so a reconnect during the wait can't be closed out from under. - The window is configurable via `idle_disconnect_delay` (default 5s, 0 = disconnect immediately). Verified the lifecycle (idle teardown, burst reuse, concurrent connects, reconnect-during-teardown guard) with a standalone asyncio test. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 10 ++++ custom_components/pppp_camera/__init__.py | 8 +++ .../pppp_camera/config_helpers.py | 13 +++- custom_components/pppp_camera/const.py | 7 +++ custom_components/pppp_camera/device.py | 60 +++++++++++++++---- 5 files changed, 84 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 00f9539..ffcd0f8 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ pppp_camera: # or single IP can also be specified (usually broadcast address) ip: 192.168.1.255 # if 'ip' is not specified, discovery will listen on all interfaces + idle_disconnect_delay: 5 # seconds to keep a session warm after the last operation ``` ### Configuration Parameters @@ -106,6 +107,15 @@ Configure automatic device discovery on your network. - Can be a list of specific IP addresses - If not specified, discovery listens on all available network interfaces +#### `idle_disconnect_delay` (optional) + +- **`idle_disconnect_delay`** (integer, default: `5`): Seconds to keep a camera + session open after the last in-flight operation completes. These cameras allow + only one client at a time, so the session is opened on demand and released when + idle. Keeping it warm briefly lets back-to-back commands (e.g. PTZ bursts) reuse + the session and prevents a fire-and-forget command from being cut off by an + immediate disconnect. Set to `0` to disconnect immediately after each operation. + ## Usage diff --git a/custom_components/pppp_camera/__init__.py b/custom_components/pppp_camera/__init__.py index ee96c2b..9206869 100644 --- a/custom_components/pppp_camera/__init__.py +++ b/custom_components/pppp_camera/__init__.py @@ -28,6 +28,8 @@ CONF_DURATION, CONF_INTERVAL, CONF_LAMP, + CONF_IDLE_DISCONNECT_DELAY, + DEFAULT_IDLE_DISCONNECT_DELAY, ) @@ -58,6 +60,10 @@ vol.Optional(CONF_IP): vol.Any(cv.string, [cv.string]), } ), + vol.Optional( + CONF_IDLE_DISCONNECT_DELAY, + default=DEFAULT_IDLE_DISCONNECT_DELAY, + ): vol.All(vol.Coerce(int), vol.Range(min=0)), } ) }, @@ -83,6 +89,8 @@ # or single IP can also be specified (usually broadcast address) ip: 192.168.1.255 # if 'ip' is not specified, discovery will listen on all interfaces + idle_disconnect_delay: 5 # seconds to keep a session warm after the last + # operation (0 = disconnect immediately) """ diff --git a/custom_components/pppp_camera/config_helpers.py b/custom_components/pppp_camera/config_helpers.py index 97d4496..c60c01a 100644 --- a/custom_components/pppp_camera/config_helpers.py +++ b/custom_components/pppp_camera/config_helpers.py @@ -5,7 +5,12 @@ from homeassistant.core import HomeAssistant from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM -from .const import CONF_DEFAULTS, DOMAIN +from .const import ( + CONF_DEFAULTS, + CONF_IDLE_DISCONNECT_DELAY, + DEFAULT_IDLE_DISCONNECT_DELAY, + DOMAIN, +) def get_config(hass: HomeAssistant) -> dict[str, Any]: @@ -23,3 +28,9 @@ def get_discovery_config(hass: HomeAssistant) -> dict[str, Any]: def get_platform_config(hass: HomeAssistant) -> dict[str, Any]: """Get configuration for DOMAIN.""" return get_config(hass).get(CONF_PLATFORM, {}) + +def get_idle_disconnect_delay(hass: HomeAssistant) -> int: + """Seconds to keep a camera session warm after the last operation.""" + return get_config(hass).get( + CONF_IDLE_DISCONNECT_DELAY, DEFAULT_IDLE_DISCONNECT_DELAY + ) diff --git a/custom_components/pppp_camera/const.py b/custom_components/pppp_camera/const.py index f80e599..e523c39 100644 --- a/custom_components/pppp_camera/const.py +++ b/custom_components/pppp_camera/const.py @@ -35,3 +35,10 @@ CONF_DURATION = "duration" CONF_INTERVAL = "interval" CONF_LAMP = "lamp" +CONF_IDLE_DISCONNECT_DELAY = "idle_disconnect_delay" + +# Seconds to keep a camera session open after the last in-flight operation +# finishes. Keeping it warm lets back-to-back commands (e.g. PTZ bursts) reuse +# the session and avoids tearing the connection down before a fire-and-forget +# command has been delivered. 0 disconnects immediately. +DEFAULT_IDLE_DISCONNECT_DELAY = 5 diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 6de2342..127400f 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -15,6 +15,8 @@ ) from homeassistant.core import HomeAssistant +from .config_helpers import get_idle_disconnect_delay + class PPPPDevice: """Manages a PPPP device.""" @@ -33,6 +35,12 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self._connected_num = 0 self._dt_diff_seconds: float = 0 + # Connection lifecycle: serialize connect/close and keep the session + # warm for a short idle window so back-to-back operations reuse it. + self._lock = asyncio.Lock() + self._idle_unload_task: asyncio.Task | None = None + self._idle_disconnect_delay: int = get_idle_disconnect_delay(hass) + async def _async_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: @@ -61,21 +69,44 @@ def dev_id(self) -> str: return self.device.descriptor.dev_id.dev_id async def connect(self): - """Connect to the device.""" - self._connected_num += 1 - if not self.device.is_connected: - await self.device.connect() + """Connect to the device, reusing a warm session when available.""" + async with self._lock: + # A new user cancels any pending idle teardown and reuses the session. + self._cancel_idle_unload() + self._connected_num += 1 + if not self.device.is_connected: + await self.device.connect() async def close(self): - """Close the connection to the device.""" - if self.device._session is None or not self._connected_num: - self._connected_num = 0 + """Release a connection reference; tear down only after an idle window.""" + async with self._lock: + if not self._connected_num: + return + self._connected_num -= 1 + if self._connected_num == 0: + # Defer teardown instead of closing inline. A command arriving + # within the idle window reuses the live session, and a + # fire-and-forget command is not cut off by an immediate Close. + self._cancel_idle_unload() + self._idle_unload_task = asyncio.create_task(self._idle_unload()) + + async def _idle_unload(self) -> None: + """Close the session once it has been idle for the configured delay.""" + try: + await asyncio.sleep(self._idle_disconnect_delay) + except asyncio.CancelledError: return - - self._connected_num -= 1 - if self._connected_num == 0: - await asyncio.sleep(1); - await self.device.close() + async with self._lock: + # Re-check under the lock: a user may have reconnected during the wait. + if self._connected_num == 0 and self.device.is_connected: + await self.device.close() + self._idle_unload_task = None + + def _cancel_idle_unload(self) -> None: + """Cancel a pending idle teardown, if any.""" + if self._idle_unload_task and not self._idle_unload_task.done(): + self._idle_unload_task.cancel() + self._idle_unload_task = None async def async_setup(self) -> None: """Set up the device.""" @@ -95,7 +126,10 @@ async def async_setup(self) -> None: async def async_stop(self, event=None): """Shut it all down.""" - await self.device.close() + async with self._lock: + self._cancel_idle_unload() + self._connected_num = 0 + await self.device.close() async def async_white_light_toggle(self, data): """Turn on the white light.""" From 2f593af22329b812656193c3422ae18077a939b1 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:38:49 +0300 Subject: [PATCH 03/42] Don't swallow CancelledError in the MJPEG stream handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_async_mjpeg_stream ended with `return response` inside a `finally`, suppressing the CancelledError raised when Home Assistant tears down the stream (client navigates away). That defeated cancellation and kept the streaming coroutine — and its warm camera session — alive. Keep only the log line in `finally` and return after the `async with` exits. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/camera.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/pppp_camera/camera.py b/custom_components/pppp_camera/camera.py index 15cb93c..17ea44f 100644 --- a/custom_components/pppp_camera/camera.py +++ b/custom_components/pppp_camera/camera.py @@ -182,7 +182,9 @@ async def handle_async_mjpeg_stream( break finally: LOGGER.info('%s camera stream closed', self.name) - return response + # Return outside the `finally` so a CancelledError raised when the client + # disconnects propagates instead of being swallowed by `return`. + return response async def async_perform_ptz( self, From 717816af2a4f4e7f55cd241dd39c77c0478e9a95 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:40:29 +0300 Subject: [PATCH 04/42] Make entity availability reflect the real connection state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PPPPDevice.available was set True once and never updated, so entities always reported available even when the camera was unplugged or unreachable. Drive it from the connect path: mark available on a successful (re)connect and unavailable when device.connect() fails, and notify entities through a dispatcher signal they subscribe to in async_added_to_hass. Also roll back the connection refcount when connect() fails — ensure_connected() does not run its close() in that case, so the reference was leaking. Note: an asynchronous mid-session drop is detected lazily, on the next operation that has to reconnect (aiopppp clears its session on loss); the library exposes no device-lost callback to hook here. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/device.py | 21 ++++++++++++++++++++- custom_components/pppp_camera/entity.py | 9 +++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 127400f..c916b66 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -14,8 +14,10 @@ Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send from .config_helpers import get_idle_disconnect_delay +from .const import DOMAIN class PPPPDevice: @@ -41,6 +43,15 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self._idle_unload_task: asyncio.Task | None = None self._idle_disconnect_delay: int = get_idle_disconnect_delay(hass) + # Entities subscribe to this signal to refresh their availability. + self.signal_available = f"{DOMAIN}_{config_entry.entry_id}_available" + + def _set_available(self, value: bool) -> None: + """Update availability and notify entities only when it changes.""" + if self.available != value: + self.available = value + async_dispatcher_send(self.hass, self.signal_available) + async def _async_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: @@ -75,7 +86,15 @@ async def connect(self): self._cancel_idle_unload() self._connected_num += 1 if not self.device.is_connected: - await self.device.connect() + try: + await self.device.connect() + except Exception: + # ensure_connected() skips close() when connect() raises, so + # roll back the reference we just took to avoid leaking it. + self._connected_num -= 1 + self._set_available(False) + raise + self._set_available(True) async def close(self): """Release a connection reference; tear down only after an idle window.""" diff --git a/custom_components/pppp_camera/entity.py b/custom_components/pppp_camera/entity.py index 2fb8537..ddf7d60 100644 --- a/custom_components/pppp_camera/entity.py +++ b/custom_components/pppp_camera/entity.py @@ -1,6 +1,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -14,6 +15,14 @@ def __init__(self, device: PPPPDevice) -> None: """Initialize the PPPP entity.""" self.device: PPPPDevice = device + async def async_added_to_hass(self) -> None: + """Refresh state when the device's availability changes.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, self.device.signal_available, self.async_write_ha_state + ) + ) + @property def available(self): """Return True if device is available.""" From 63016cb07d1aba5f130340a7f358ce5aca719169 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:42:06 +0300 Subject: [PATCH 05/42] Seed lamp state from the camera and confirm before committing Lamp switches/lights always started 'off' and flipped _attr_is_on before the command was sent, so a lamp already on at startup showed as off and a failed command still moved the UI. Initialize the state from the camera's reported properties (lamp/icut) and only update it after the command succeeds. Mark the entities assumed_state, since these cameras can't reliably report lamp state back. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/const.py | 3 +++ custom_components/pppp_camera/light.py | 16 ++++++++++++---- custom_components/pppp_camera/switch.py | 16 ++++++++++++---- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/custom_components/pppp_camera/const.py b/custom_components/pppp_camera/const.py index e523c39..d98ab1e 100644 --- a/custom_components/pppp_camera/const.py +++ b/custom_components/pppp_camera/const.py @@ -37,6 +37,9 @@ CONF_LAMP = "lamp" CONF_IDLE_DISCONNECT_DELAY = "idle_disconnect_delay" +# Maps a lamp entity key to the camera property that reports its on/off state. +LAMP_STATE_PROPERTY = {"white_lamp": "lamp", "ir_lamp": "icut"} + # Seconds to keep a camera session open after the last in-flight operation # finishes. Keeping it warm lets back-to-back commands (e.g. PTZ bursts) reuse # the session and avoids tearing the connection down before a fire-and-forget diff --git a/custom_components/pppp_camera/light.py b/custom_components/pppp_camera/light.py index 1dd0bda..e0fd62a 100644 --- a/custom_components/pppp_camera/light.py +++ b/custom_components/pppp_camera/light.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, CONF_LAMP +from .const import DOMAIN, CONF_LAMP, LAMP_STATE_PROPERTY from .device import PPPPDevice from .entity import PPPPBaseEntity from .config_helpers import get_platform_config @@ -80,6 +80,8 @@ class PPPPLight(PPPPBaseEntity, LightEntity): # Set supported color modes for on/off lights _attr_supported_color_modes = {ColorMode.ONOFF} _attr_color_mode = ColorMode.ONOFF + # These cameras can't reliably report lamp state, so it is assumed. + _attr_assumed_state = True def __init__( self, device: PPPPDevice, description: PPPPLightEntityDescription @@ -87,21 +89,27 @@ def __init__( """Initialize the light.""" super().__init__(device) - self._attr_is_on = False self._attr_unique_id = f"{self.device.dev_id}_{description.key}" #self._attr_name = description.translation_key self.entity_description = description + # Seed from the camera's reported state instead of always starting off. + prop = LAMP_STATE_PROPERTY.get(description.key) + self._attr_is_on = bool(device.device.properties.get(prop)) if prop else False async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" - self._attr_is_on = True await self.entity_description.turn_on_fn(self.device)( self.entity_description.turn_on_data ) + # Commit state only after the command succeeds, so a failed command + # doesn't leave the UI showing the wrong state. + self._attr_is_on = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" - self._attr_is_on = False await self.entity_description.turn_off_fn(self.device)( self.entity_description.turn_off_data ) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/custom_components/pppp_camera/switch.py b/custom_components/pppp_camera/switch.py index ed91f1d..572cf1c 100644 --- a/custom_components/pppp_camera/switch.py +++ b/custom_components/pppp_camera/switch.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, CONF_LAMP +from .const import DOMAIN, CONF_LAMP, LAMP_STATE_PROPERTY from .device import PPPPDevice from .entity import PPPPBaseEntity from .config_helpers import get_platform_config @@ -77,6 +77,8 @@ class PPPPSwitch(PPPPBaseEntity, SwitchEntity): entity_description: PPPPSwitchEntityDescription _attr_has_entity_name = True + # These cameras can't reliably report lamp state, so it is assumed. + _attr_assumed_state = True def __init__( self, device: PPPPDevice, description: PPPPSwitchEntityDescription @@ -84,21 +86,27 @@ def __init__( """Initialize the switch.""" super().__init__(device) - self._attr_is_on = False self._attr_unique_id = f"{self.device.dev_id}_{description.key}" #self._attr_name = description.translation_key self.entity_description = description + # Seed from the camera's reported state instead of always starting off. + prop = LAMP_STATE_PROPERTY.get(description.key) + self._attr_is_on = bool(device.device.properties.get(prop)) if prop else False async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" - self._attr_is_on = True await self.entity_description.turn_on_fn(self.device)( self.entity_description.turn_on_data ) + # Commit state only after the command succeeds, so a failed command + # doesn't leave the UI showing the wrong state. + self._attr_is_on = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" - self._attr_is_on = False await self.entity_description.turn_off_fn(self.device)( self.entity_description.turn_off_data ) + self._attr_is_on = False + self.async_write_ha_state() From db24ab96fb931a427227f11bfe9798d59edd8467 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:43:03 +0300 Subject: [PATCH 06/42] Fix supported_fn type hint on light entity description It was annotated Callable[[PPPPDevice], bool] but is called with (device, hass), like the switch and button descriptions. Correct the hint. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/pppp_camera/light.py b/custom_components/pppp_camera/light.py index e0fd62a..05f9e05 100644 --- a/custom_components/pppp_camera/light.py +++ b/custom_components/pppp_camera/light.py @@ -30,7 +30,7 @@ class PPPPLightEntityDescription(LightEntityDescription): ] turn_on_data: Any turn_off_data: Any - supported_fn: Callable[[PPPPDevice], bool] + supported_fn: Callable[[PPPPDevice, HomeAssistant], bool] LIGHTS: tuple[PPPPLightEntityDescription, ...] = ( From 87db976c4b2ba6af33a6463c5a9b3d5d220d68f6 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:10:53 +0300 Subject: [PATCH 07/42] Report real camera streaming state and support turn on/off The camera entity hard-coded _attr_is_streaming = True, so it always reported "streaming" regardless of reality. Surface the actual state and let it be controlled: - A: is_streaming now derives from the live session (is_connected and is_video_requested) instead of a constant. - B: subscribe to a new per-device "streaming" dispatcher signal; PPPPDevice forwards the library's on_video_state_change callback to it, so the entity refreshes whenever streaming starts or stops for any reason (including a stalled-stream drop or session teardown). - C: advertise CameraEntityFeature.ON_OFF with async_turn_on/async_turn_off mapped to start_video/stop_video. turn_on holds a connection reference so the stream persists until turn_off releases it. Requires the aiopppp on_video_state_change callback (new in the library). Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/camera.py | 43 +++++++++++++++++++++++-- custom_components/pppp_camera/device.py | 13 ++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/custom_components/pppp_camera/camera.py b/custom_components/pppp_camera/camera.py index 17ea44f..eb77925 100644 --- a/custom_components/pppp_camera/camera.py +++ b/custom_components/pppp_camera/camera.py @@ -8,10 +8,15 @@ import aiopppp import voluptuous as vol from aiohttp import web -from homeassistant.components.camera import Camera, CameraEntityDescription +from homeassistant.components.camera import ( + Camera, + CameraEntityDescription, + CameraEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import uuid @@ -105,8 +110,8 @@ async def async_setup_entry( class PPPPCamera(PPPPBaseEntity, Camera): """An implementation of a PPPP camera.""" - _attr_is_streaming = True _attr_has_entity_name = True + _attr_supported_features = CameraEntityFeature.ON_OFF description = CameraEntityDescription(key = "camera", translation_key = "camera") def __init__(self, device: PPPPDevice) -> None: @@ -116,6 +121,40 @@ def __init__(self, device: PPPPDevice) -> None: #self._attr_name = self.device.dev_id self._attr_unique_id = f'{self.device.dev_id}_camera' + # True while explicitly turned on via camera.turn_on, which holds a + # connection reference open so streaming persists until turned off. + self._stream_hold = False + + async def async_added_to_hass(self) -> None: + """Subscribe to availability (base) and streaming-state updates.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, self.device.signal_streaming, self.async_write_ha_state + ) + ) + + @property + def is_streaming(self) -> bool: + """Return True only while video is actively being streamed.""" + dev = self.device.device + return dev.is_connected and dev.is_video_requested + + async def async_turn_on(self) -> None: + """Start streaming and keep the session open until turned off.""" + if not self._stream_hold: + # Hold a connection reference so the session isn't idle-closed. + await self.device.connect() + self._stream_hold = True + await self.device.device.start_video() + + async def async_turn_off(self) -> None: + """Stop streaming and release the held session.""" + if self.device.device.is_connected and self.device.device.is_video_requested: + await self.device.device.stop_video() + if self._stream_hold: + self._stream_hold = False + await self.device.close() @cached_property def use_stream_for_stills(self) -> bool: diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index c916b66..7d8cd1e 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -13,7 +13,7 @@ CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .config_helpers import get_idle_disconnect_delay @@ -43,8 +43,9 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self._idle_unload_task: asyncio.Task | None = None self._idle_disconnect_delay: int = get_idle_disconnect_delay(hass) - # Entities subscribe to this signal to refresh their availability. + # Entities subscribe to these signals to refresh availability / stream state. self.signal_available = f"{DOMAIN}_{config_entry.entry_id}_available" + self.signal_streaming = f"{DOMAIN}_{config_entry.entry_id}_streaming" def _set_available(self, value: bool) -> None: """Update availability and notify entities only when it changes.""" @@ -52,6 +53,11 @@ def _set_available(self, value: bool) -> None: self.available = value async_dispatcher_send(self.hass, self.signal_available) + @callback + def _on_video_state_change(self, is_streaming: bool) -> None: + """Forward the library's streaming-state change to subscribed entities.""" + async_dispatcher_send(self.hass, self.signal_streaming) + async def _async_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: @@ -134,6 +140,7 @@ async def async_setup(self) -> None: host=self.config_entry.options[CONF_HOST], username=self.config_entry.options[CONF_USERNAME], password=self.config_entry.options[CONF_PASSWORD], + on_video_state_change=self._on_video_state_change, ) async with self.ensure_connected(): @@ -478,10 +485,12 @@ def get_device( host: str, username: str | None, password: str | None, + on_video_state_change=None, ) -> aiopppp.Device: """Get Device instance.""" return aiopppp.Device( host, username=username, password=password, + on_video_state_change=on_video_state_change, ) From 38c6539dcd369e4608296925ee194d3d49d7e25e Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:10:36 +0300 Subject: [PATCH 08/42] Show the camera IP and device ID in the device-info card Surface the configured IP in the Firmware field (sw_version): the camera has no web UI (so a configuration_url "Visit" link is useless) and reports its own ipAddr as zeros, so this is the only way to show the address as plain text, like some other integrations do. Use the full device ID (e.g. PTZA-...-...) as both model and serial_number; drop the bare numeric serial and the manufacturer that added no value. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/entity.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/custom_components/pppp_camera/entity.py b/custom_components/pppp_camera/entity.py index ddf7d60..7903b88 100644 --- a/custom_components/pppp_camera/entity.py +++ b/custom_components/pppp_camera/entity.py @@ -35,8 +35,13 @@ def device_info(self) -> DeviceInfo: camera_properties = self.device.device.properties return DeviceInfo( identifiers={(DOMAIN, self.device.dev_id)}, - hw_version=camera_properties.get('mcuver'), - sw_version=camera_properties.get('sysver'), model=self.device.dev_id, model_id=camera_properties.get('sensor'), + serial_number=self.device.dev_id, + hw_version=camera_properties.get('mcuver'), + # The camera has no web UI (so a configuration_url "Visit" link is + # useless) and reports its own ipAddr as zeros. Surface the + # configured IP in the Firmware field instead: not strictly + # accurate, but it makes the address visible as plain text. + sw_version=self.device.host, ) From 2e56ec3f867ea6963d4f3ea0f83a91172cc2dea3 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:10:36 +0300 Subject: [PATCH 09/42] Add integration brand icon (HA camera glyph + PPPP label) Local brand images (HA 2026.3+): Home Assistant's stock generic-camera icon with a small Arial Black "PPPP" label composited into the bottom-right of the camera body -- so the glyph is pixel-crisp and exactly HA's size, not a smaller re-drawn copy. icon.png (256x256) and icon@2x.png (512x512). Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/brand/icon.png | Bin 0 -> 2643 bytes custom_components/pppp_camera/brand/icon@2x.png | Bin 0 -> 5345 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 custom_components/pppp_camera/brand/icon.png create mode 100644 custom_components/pppp_camera/brand/icon@2x.png diff --git a/custom_components/pppp_camera/brand/icon.png b/custom_components/pppp_camera/brand/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a21283b2f5af05f5cc2c810ecefc18a3d9168fd9 GIT binary patch literal 2643 zcmds3`#;lt8~<#UQ%(~_GZX3LRFW**TS6ovp{N}0LdoHw9PY{%-K4{~??jnXDLTk$ zj;R(~$Z_c=Y%J1km~9@}u-Tr?AJGrbPuKgpzSs4Bf3ENM`?{`AhNs72Rprgf00322 z7sulOK%@`?6y>GnbnNp`0F)J79S@#J%xBYa&quEy+vf!WMjXz4KeA_fC79%Da_^{b z2su=zb;rt~w*o^a_yC4rpm_VUdWQeUTOHz#6g@|W)&chn+H;=YU)w5NFO;I|ek3=d ziNu6vOfMRRDKaAV7LXQZCAaKJ-oiBMyZQ&KvL!-u49m$RXPg=mGv8aUD*OqkN8>67JRn024#xjF}OJV0sYHCj#o zsCe`tq@a@_hzt=0q!FIN@hkPXaN>MVCI^kP|K?BBDX^|JyVndB!q0b=H6~f*25;#GBd4!J$D)mzj@}DsJ}@$Yw+3CO(y0zUe=nJ;YN;#c9%po8+n4P1-qmuyl;e zxL1%q*Xhy)q<4lIE{ns30;enm+$}2^b2%sT-WJkid^XOPWF9O5$2s}@#;M`!~BbC?i>>T#< zIZkXE*`5tN?Rwu|3}0^xusRTM6aH%q79RQEN(sW>5bP+vL~QE!afZoo=?l0aO&*G$ zFoc>(Pmh4x7)u=^s5=~l8$hKEu?(Qww*Z<0%mXX!T2W8hdxqja(zqcM1}3ORSOCTq zvsKGJ77eP=+jAS30aJl{wModmy;0)mfn?FS#i!)BFL^cB=To-KQoYFCy+*zDoR`Du zk~7tm2ODtW;Rf~LK65krgc{-QZAq2Fzxksi=I}9zcb!6D`Qr5+mIJJGse^^rX zY+DKv`@~Xm;EQ^@co8 zuHCYkA8FjYU_{|B4iq>|Uw%FO_csqbn8fM`>ykZjv951b15Pf4pt2ETe)S3F9DCDt zQ{*0c)tgI!Cv~HPgw>(mA#ekLhCBZ}6uoPB#pgfi-wZLLY6TM&_LN_neeH@g!$Fpz z>7g`een)uBnw=|T)g;>De{a3eR_04xEnhuJ+WD8F1|C8be64>kNF1o4|2tFJXIC4P zsdstt4*p*4RbivA*H2?Lc@CA&xe2H_gEJW#=tAT#POT?gX}kR+E-_Ov_%k>O1`m! zXI*p{i^)CAxflEH4rEDgxpfv??@cZrCZ!z4gRMJM*f*BjFzX?Svx!rOc$2|leaOg- z54wBxr=zMIPHf!Fc)UJtn)4<1aQyz1frf%y7|2WFjZEKpG~T7P;IM@fSZ)>NFODqHP##1)V`C8>h!x%|&dVS}K=_5aoGk?}pkb*#wP?gmkX{OS1oq z<=-VJi@N<_T&X3#Nb_22k^W8Zo4&2)t?qaIZO=YT;PdXJo+4E$^Qi{5c)ejRc{RD& zmSa?HAvED6aPBpS>U<{a$9SQbbM1L&OlY#-f?CcjjZUujC1Z8m8qFH7_zgrcyy_l` zrxSJ}P?aZ-N^MfkP{>22{-rrel6{{4?(=b)7Bqo$c(u zvvUe6Ios3(L)p^TwjWL2h6VDbjFFL_jsq1qQx06e5GLxcZ;VKb{>H04I}=eDk0yXJ zfK@@;BiE+pUFnVw={yCKPSOB^p>zx;ceqL&ng#mShYR8#GxAXd?(Au`$w2G#_e&c5 zca=_kwR-1dgSMvW9t~E=S|EOK@$_#$aecd|@F^~#L-W1^;BVIO-bs&q@yP!t9)oP` z*R(~tYyo{b8kwXS1}WzWECH$nGidI@pUl5HRJxJdaKgBLgU&UOPoIz=1Ol(eCS9dw zSCr;p(xvjEx=i%$R-i7edDPnakCGn;r9uH=a@I?9d`r$FZC}L5zs%RWi*0*x!eb*; z*2Y8h{zMH{-d_Ha@Ts3L;jk*}Hob$FU|z5tvVG%fPfdUNYT+bKzP2wA(`h|4xwM!@ zc(%BMN9~AzwZ6myp%vA=R)|FX@nCNyLM0FX#V$IvBG dATPTU%7Gq5)r(t;R%Dk&;OgYzSm6+q@;_=+T$2C* literal 0 HcmV?d00001 diff --git a/custom_components/pppp_camera/brand/icon@2x.png b/custom_components/pppp_camera/brand/icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..641d73a7bcfb1559881047625e85bc2151649a8a GIT binary patch literal 5345 zcmeHLYdnaNgaJTT7XU476abqy190*r0G7w#qUGiPHTj?SOTa-Ddr!AeVf!Cn@{xmi zF@X!uMt0=s9}o?-3)Ht+`rWWrTDH;X`M$XLZr-ZX-hEyw{u-4#GG$#pQ+dl89ReQ< zTJ+?_J^|ZV%sVdTjI4kV#=PdxDXDocxXX!&*-TpOkDvMC*utoeUL0s?y9sc4chu6= zqs}il&NFsmT*Y=;0(E;@r)cg2oJ5$jVzwWUxes6ax26V%c}c05pss0&JT#TUQbg^w zF;~FdMyG{S05^54NLkGOSoVmvYi{tQ#KM05gVi|9jlA! z5Ro}DciRF-e#;K)b})ahf+ZyeQr7dIwpnWsq)DN9lWvVo$|3`A9EO=tF?zI$FC6Pi zMj5FhTe-Z|dnCEC&TrmaUVQKZmnP<2I-Oc`<9g7*^*CnI52)UJdK=PK7!MvrT8HhP z&-nf8&DRz>4;4TxFZyHJn&6gG?cU0n7%QWjyjiMOjTU4Imwu^BVs9a3okQesZsr2+ zFBS@pqHtY(;6Z=tb_g-RSf0u=-DmMO%E?GQHW6{b>&t>bH=$eC9(1O4KE3SrJrjYZ z6)5)_Ki`ywv3#&QO^3a!9-(3^J*nHkXiUt_%Up$o-&KTbdLS#_6+8}F$Z@_cR253- zf~>>Z;IwPgoE3r-C8SHGN|8!6lndB0(+h_LGSX3@?xE=tzs2qGav?^5+8axm6{L##zQ#qH8SE4GpRU)>Ch1f8R0^>>cb~&f`e97h0bu|5(~2E99*9Ia}GWN z4wmra6Z9YrBjF5ZE+Il1y6wnA4t^2RGQRv=EdZiO94Z-3Aybc6LIDV14*%FXOE?9< zWj}r{^g`)=fioPZswy1c3`jvZdU*d=1QQ-d1oVElkAmMPxb{psz^LlEWEC3yU z&idmqfLXp@UZuVMbi=HF#s+O97*Bv+7ANE5U}^C5=bJ$5&%R{5 z*ar0Fm!Z%gGX;kngcT(G9;ZzfPGw*$P?d*PN@V{Uf3>-JdHnTA7D*erd6af2vW;qhR@n)ix~a~cr-;Hm z0^sWwIi@TOw}BiAmki-H2sgYhj|zeU`MO(DCx{O0A@P~jOm|63vbYtN`*dP=UQaSiXn zn;nq{2ckbsA2y)prxIAT+cX&r5l@io>^I^<=u>>v9yHd!7Tt7R!;R(K#EtycUAfTc z8R3NxC`)m3{aDi~i=$h&iuUA1#T!Kw045lFhM3v5oH#P_@oKP9{6zJ$k^Td2X)K@7g;1&8So%y`Oh;@TL+nUw&q; zt7B|9o3M9zhqs*xfq;+MX%NL4<4-Dl@+_EYW@!G3{nY~v$gefT@m)AlnuY(tc+S`B zqUef&mGb&LKG#1u6(4oA&$fuSrBRL0tH=%7w|3XpZE4-ont4th7@>J`oPlRM-aEcv zYm#jb+4T`u=gxePDOft!SiGI~9V^CZg|aWgm%V%jW3Pb%JFd?^h!wXQ?_ZcEn!POu zL@)WibblS-wkMvK_({||(ae~6hW&Ln8rb}HR2a+h@f)7XeT;F zE5J<3=v4Q-!D*h=Kg2EaYQJ@>>!M=scF!AD|9p%k%%v)CoBni}E3l;?R=u2O%j%w# zkevRo<}AsY(iw@J=)*0K04JU*3Ndt`1*~Y-1yUNkzVV-$7Ot#}j(z3NNxFPn3uR=l zm^*AAR^qq~dNw6fJ!2YXkhkvdk@&F7f_eBkZxR>lGI&px^kqUzSY`5aHFmDU2|a}t ze=na;%Nwq4iDVS?wJudwSEJHL-uYEzd2V{gkFAeJd$}#eJvd@qQU*nZDnDKj*Et!; zsFXjCuT%F55I=#)d^?s3jrHQ82WGfSEgY@Eu>nPpZU(xz!$OpHZ;8%HqW{n8J z$btMhfuTW@nt^AsIb}^`UKx~^zWlvSp50pfZ~-R#As|IurP_(Q_qdIWd9D+>k2k&R z?v^g9x*6shoSo?<=_9m`>Zx+=VNf%BxH%y&t`H^F+1|Wn26dO*XEQWdz4l|6V$=#q zh!`wlklh5KiQq6oCo8GQ##_-#P4C}$r+njJ@F zZfTm=deC-w=ZG!j(PLl61vreukfms&@8|>XjDCrl^0phuu=cvlt-u?p#PN4=l_GPh zSuf?wU}N5KtGuE2M7DW9Z!ne^M=RvLN=zfV z?iP1n7YW;6q|DpAf$xoap>TFD3M|L|Sk`c!YOGlw=@wQV^M^*g8k$$4`5}^3B_G-+ zTYR0ExL$qUzbBr|yQA%OkNqT$?XHt~(gDRx@{Ay8h@$OQC?i$NHC0uAKbdCjJZG?a zQ6hwxG(ENU_S)f0Q@DcymajJC689mXTxNLXeK5U(EAe3N@wrpk6HAf_mF#!hh^sa$ z#}`Xs;vFe2Rz$wR@4<)acn@O$&fOfakl>Z$6&gYjYj3WIqcx((~Q3;@6(!hc;3uDHd5Fbvl{!`_m_s4V#pSd)5?Bz}*sR z&1{I020>?I?PCTmX=u`3D$l&T8o`UKajHlVIahrfE_C}P?t6OYpR2_~)!cPK)osJy zCfgZ?>0FHcPaEpeoT0f6B#w24)))Dm9Qtye)=p~}fVnk@@*DALs{O!^qCcLwjnwy0 zsxo+v@i5$4>#ePaEvjBsRc2&Mbr|s09OO|x`rxnsOB!aUd!!c>@HEBjK*GM-B6sPT z_ea-YhH@rg?mh|J=sW|_jj)9}yH(C;E9@=jlSmI>$$NYXbN4aq_%7&pUxq!KuLA0y zCYFj-IlR&0CXrsk>KC4fRe>Gvw#w9A05Eqp?gA{UAO%~k@HA|%(S~I*NeULoa0*uC z2c^D@ssZIFv21h0)(gE`r`XktFXNDuzn)6I@y}Eu9rap}vZPe(LD?-BUP%qCQ!hvX zNxe$2PfzDG{!Y)TF^_Cnf8K$b+yQi^H4OfKBey|7SN@Dtn;2+bs5bQx&U>3$Gmm3Q zuCeijcf!z_yB57R)sIe-G-W~TzCwZT0yhJ7_V(e{G!vO>Qx(S2H0eXNvh8)WcX65Y zz?TBFAR5@ShTitCb$EyOV^2DOBfYAUyb3_Yl1}C2_cRm}=N>!RoCZe*rv&-w*;Pag z^K$Ku$`;x@^?}{G=f|~*b%sw?`js~NQ9aO@vugR=c-h?1g^~08K^eNlaG-Kf(Ig+= zQM(!g?l#dz=BwEU1ggq=fJ7b43Sola^OQ}c?#E8+Bf)fM*DZujCK3pImOR=e`00Z!E>Mc9T}9xH+;gn#dM{=L-t sZ*KBd5qLi-66vK?vfzXGMphC7x_1N25{~p*!+*f36Asoz$GmU<2R(n?82|tP literal 0 HcmV?d00001 From 3741d7e1c9412cc31a2ddfaf18320615afb6108b Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:51:30 +0300 Subject: [PATCH 10/42] Retry setup when the camera is lost during connect aiopppp now handles a P2pRdy/handshake timeout cleanly, so Device.connect() raises NotConnectedError ("Device lost during connection") instead of a bare TimeoutError. Catch it too in async_setup_entry so a camera that is briefly flaky at startup yields ConfigEntryNotReady (auto-retry) instead of a failed config entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/pppp_camera/__init__.py b/custom_components/pppp_camera/__init__.py index 9206869..535bab3 100644 --- a/custom_components/pppp_camera/__init__.py +++ b/custom_components/pppp_camera/__init__.py @@ -15,6 +15,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv import voluptuous as vol +from aiopppp import NotConnectedError from .camera import PPPPCamera from .discovery import async_start_discovery @@ -115,7 +116,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b device = PPPPDevice(hass, config_entry) try: await device.async_setup() - except TimeoutError as err: + except (TimeoutError, NotConnectedError) as err: + # NotConnectedError is raised when the camera is found but the session is + # lost during connect (e.g. P2pRdy/handshake timeout) -- retry, don't fail. await device.device.close() raise ConfigEntryNotReady( f"Could not connect to camera {device.device.ip_address}: {err}" From 37441e170155144f3bf52665507199cb5254f597 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:11:23 +0300 Subject: [PATCH 11/42] Fix unload/reload lifecycle leaks async_unload_entry unloaded only PLATFORMS (camera), leaving the lamp and button entities orphaned on unload/reload, and it never tore down the warm session or dropped the hass.data reference -- so the session, socket and pending idle-unload task leaked across reloads. Unload the platforms that were actually set up, then async_stop() the device and pop it from hass.data. Also stop registering a second options-update listener in async_setup_entry: PPPPDevice.async_setup already registers one that reloads the entry, so the pair caused a double reload on every options change. Guard the connect-failure cleanup against device.device not existing yet, and let _idle_unload swallow cancellation that arrives while awaiting the lock (not only during the sleep). Drop the leftover `import select` and the now-unused async_reload_entry. Co-Authored-By: Claude Opus 4.8 --- custom_components/pppp_camera/__init__.py | 29 ++++++++++++++--------- custom_components/pppp_camera/device.py | 12 ++++++---- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/custom_components/pppp_camera/__init__.py b/custom_components/pppp_camera/__init__.py index 535bab3..e2d92ad 100644 --- a/custom_components/pppp_camera/__init__.py +++ b/custom_components/pppp_camera/__init__.py @@ -1,6 +1,5 @@ """The PPPP IP Camera integration.""" -import select from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, @@ -119,9 +118,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except (TimeoutError, NotConnectedError) as err: # NotConnectedError is raised when the camera is found but the session is # lost during connect (e.g. P2pRdy/handshake timeout) -- retry, don't fail. - await device.device.close() + # device.device may not exist yet if setup failed very early; guard it. + if getattr(device, "device", None) is not None: + await device.device.close() raise ConfigEntryNotReady( - f"Could not connect to camera {device.device.ip_address}: {err}" + f"Could not connect to camera {device.host}: {err}" ) from err hass.data[DOMAIN][config_entry.unique_id] = device @@ -135,8 +136,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(config_entry, device.platforms) - # Reload entry when its updated. - config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry)) + # PPPPDevice.async_setup() already registers an options-update listener that + # reloads the entry, so don't register a second one here (it would reload twice). config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.async_stop) ) @@ -145,9 +146,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) + device: PPPPDevice | None = hass.data.get(DOMAIN, {}).get(entry.unique_id) + # Unload the platforms that were actually set up (camera + lamp + button), + # not just PLATFORMS (camera only) -- otherwise the lamp/button entities are + # orphaned on unload/reload. + platforms = device.platforms if device and device.platforms else PLATFORMS + unloaded = await hass.config_entries.async_unload_platforms(entry, platforms) + if unloaded and device is not None: + # Tear the warm session down and drop the reference so nothing leaks + # across reloads. + await device.async_stop() + hass.data[DOMAIN].pop(entry.unique_id, None) + return unloaded diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 7d8cd1e..fbb0c33 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -119,13 +119,15 @@ async def _idle_unload(self) -> None: """Close the session once it has been idle for the configured delay.""" try: await asyncio.sleep(self._idle_disconnect_delay) + async with self._lock: + # Re-check under the lock: a user may have reconnected during the wait. + if self._connected_num == 0 and self.device.is_connected: + await self.device.close() + self._idle_unload_task = None except asyncio.CancelledError: + # Cancellation can arrive during the sleep or while awaiting the lock; + # either way there is nothing to clean up (a reconnect took over). return - async with self._lock: - # Re-check under the lock: a user may have reconnected during the wait. - if self._connected_num == 0 and self.device.is_connected: - await self.device.close() - self._idle_unload_task = None def _cancel_idle_unload(self) -> None: """Cancel a pending idle teardown, if any.""" From ab006547be66542884cf645542288887147b5dd4 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:14:04 +0300 Subject: [PATCH 12/42] Fix camera/lamp/button entity bugs - async_perform_reboot went straight to session.reboot(), which fails when the session was idle-closed; route it through device.async_reboot() so it reconnects first. Fix its docstring ("PTZ action" -> reboot). - PTZ dropped the tilt axis when both pan and tilt were supplied (elif); apply both independently. - Lamp entities gated IR availability on the white-lamp "lamp" property in all three platforms; gate each on its own property via LAMP_STATE_PROPERTY so an IR-only or lamp-only camera exposes the right entity. - The reboot button was gated on the unrelated "auth" login flag (hidden when login failed); always expose it. - Set should_poll=False on the base entity: these are dispatcher-driven / assumed-state and implement no async_update, so polling was wasted work. - Drop a duplicate "Getting camera image" log line. Co-Authored-By: Claude Opus 4.8 --- custom_components/pppp_camera/button.py | 11 +++++++---- custom_components/pppp_camera/camera.py | 11 +++++++---- custom_components/pppp_camera/entity.py | 4 ++++ custom_components/pppp_camera/light.py | 4 ++-- custom_components/pppp_camera/switch.py | 4 ++-- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/custom_components/pppp_camera/button.py b/custom_components/pppp_camera/button.py index 1711713..219e773 100644 --- a/custom_components/pppp_camera/button.py +++ b/custom_components/pppp_camera/button.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, CONF_LAMP +from .const import DOMAIN, CONF_LAMP, LAMP_STATE_PROPERTY from .device import PPPPDevice from .entity import PPPPBaseEntity from .config_helpers import get_config, get_platform_config @@ -33,7 +33,10 @@ class PPPPButtonEntityDescription(ButtonEntityDescription): translation_key="reboot", press_fn=lambda device: device.async_reboot, press_data=None, - supported_fn=lambda device, _: bool(device.device.properties.get("auth", False)), + # Reboot is always available on a set-up camera; it was previously gated + # on the unrelated "auth" login flag, which hid it whenever login failed + # even though reboot works without auth. + supported_fn=lambda device, _: True, device_class = ButtonDeviceClass.RESTART, entity_category = EntityCategory.CONFIG, ), @@ -42,7 +45,7 @@ class PPPPButtonEntityDescription(ButtonEntityDescription): translation_key="white_lamp", press_fn=lambda device: device.async_white_light_toggle, press_data=None, - supported_fn=lambda device, hass: CONF_LAMP in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.BUTTON, + supported_fn=lambda device, hass: LAMP_STATE_PROPERTY["white_lamp"] in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.BUTTON, icon="mdi:lightbulb" ), PPPPButtonEntityDescription( @@ -50,7 +53,7 @@ class PPPPButtonEntityDescription(ButtonEntityDescription): translation_key="ir_lamp", press_fn=lambda device: device.async_ir_light_toggle, press_data=None, - supported_fn=lambda device, hass: CONF_LAMP in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.BUTTON, + supported_fn=lambda device, hass: LAMP_STATE_PROPERTY["ir_lamp"] in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.BUTTON, icon="mdi:lightbulb-night" ), ) diff --git a/custom_components/pppp_camera/camera.py b/custom_components/pppp_camera/camera.py index eb77925..bf401ca 100644 --- a/custom_components/pppp_camera/camera.py +++ b/custom_components/pppp_camera/camera.py @@ -176,7 +176,6 @@ async def async_camera_image( if not video_streaming: await self.device.device.start_video() - LOGGER.info('Getting camera image') image_frame = await self.device.device.get_video_frame() if not video_streaming: await self.device.device.stop_video() @@ -238,9 +237,11 @@ async def async_perform_ptz( ) -> None: """Perform a PTZ action on the camera.""" async with self.device.ensure_connected(): + # pan and tilt are independent axes; apply both when both are given + # (the previous elif silently dropped tilt when pan was also set). if pan: await self.device.device.session.step_rotate(pan) - elif tilt: + if tilt: await self.device.device.session.step_rotate(tilt) # await self.device.async_perform_ptz( @@ -258,5 +259,7 @@ async def async_perform_ptz( async def async_perform_reboot( self, ) -> None: - """Perform a PTZ action on the camera.""" - await self.device.device.session.reboot() + """Reboot the camera.""" + # Go through the device helper so the session is (re)connected if it was + # idle-closed; calling session.reboot() directly fails when disconnected. + await self.device.async_reboot(None) diff --git a/custom_components/pppp_camera/entity.py b/custom_components/pppp_camera/entity.py index 7903b88..4c33aa1 100644 --- a/custom_components/pppp_camera/entity.py +++ b/custom_components/pppp_camera/entity.py @@ -11,6 +11,10 @@ class PPPPBaseEntity(Entity): """Base class common to all PPPP entities.""" + # These entities push state via the dispatcher / are assumed-state; none of + # them implement async_update, so polling would just be wasted no-op calls. + _attr_should_poll = False + def __init__(self, device: PPPPDevice) -> None: """Initialize the PPPP entity.""" self.device: PPPPDevice = device diff --git a/custom_components/pppp_camera/light.py b/custom_components/pppp_camera/light.py index 05f9e05..eb44d98 100644 --- a/custom_components/pppp_camera/light.py +++ b/custom_components/pppp_camera/light.py @@ -41,7 +41,7 @@ class PPPPLightEntityDescription(LightEntityDescription): turn_off_data=None, turn_on_fn=lambda device: device.async_white_light_on, turn_off_fn=lambda device: device.async_white_light_off, - supported_fn=lambda device, hass: CONF_LAMP in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.LIGHT, + supported_fn=lambda device, hass: LAMP_STATE_PROPERTY["white_lamp"] in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.LIGHT, icon="mdi:flashlight" ), PPPPLightEntityDescription( @@ -51,7 +51,7 @@ class PPPPLightEntityDescription(LightEntityDescription): turn_off_data=None, turn_on_fn=lambda device: device.async_ir_light_on, turn_off_fn=lambda device: device.async_ir_light_off, - supported_fn=lambda device, hass: CONF_LAMP in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.LIGHT, + supported_fn=lambda device, hass: LAMP_STATE_PROPERTY["ir_lamp"] in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.LIGHT, icon="mdi:lightbulb-night", ), ) diff --git a/custom_components/pppp_camera/switch.py b/custom_components/pppp_camera/switch.py index 572cf1c..68a0ab4 100644 --- a/custom_components/pppp_camera/switch.py +++ b/custom_components/pppp_camera/switch.py @@ -41,7 +41,7 @@ class PPPPSwitchEntityDescription(SwitchEntityDescription): turn_off_data=None, turn_on_fn=lambda device: device.async_white_light_on, turn_off_fn=lambda device: device.async_white_light_off, - supported_fn=lambda device, hass: CONF_LAMP in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.SWITCH, + supported_fn=lambda device, hass: LAMP_STATE_PROPERTY["white_lamp"] in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.SWITCH, icon="mdi:lightbulb" ), PPPPSwitchEntityDescription( @@ -51,7 +51,7 @@ class PPPPSwitchEntityDescription(SwitchEntityDescription): turn_off_data=None, turn_on_fn=lambda device: device.async_ir_light_on, turn_off_fn=lambda device: device.async_ir_light_off, - supported_fn=lambda device, hass: CONF_LAMP in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.SWITCH, + supported_fn=lambda device, hass: LAMP_STATE_PROPERTY["ir_lamp"] in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.SWITCH, icon="mdi:lightbulb-night", ), ) From b32cc04e2711bd2df33901c170aa498a738ad2e7 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:15:51 +0300 Subject: [PATCH 13/42] Fix discovery loop: track task, persist dedup, quiet log spam - The discovery loop ran via a detached hass.loop.create_task and was never cancelled, so it kept running with zero config entries and past shutdown. Use hass.async_create_background_task, keep a handle, and cancel it on EVENT_HOMEASSISTANT_STOP. - A fresh PPPPDiscovery was built every iteration, resetting the "already discovered" set, so each known camera re-raised a discovery flow every interval. Reuse a single instance so dedup persists. - Misconfigured/empty discovery IPs raised HomeAssistantError that the loop logged as an error every interval; return [] and warn once instead. Co-Authored-By: Claude Opus 4.8 --- custom_components/pppp_camera/discovery.py | 44 +++++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/custom_components/pppp_camera/discovery.py b/custom_components/pppp_camera/discovery.py index 0e97b13..8a1c1a8 100644 --- a/custom_components/pppp_camera/discovery.py +++ b/custom_components/pppp_camera/discovery.py @@ -13,10 +13,10 @@ CONF_DEVICE_ID, CONF_DISCOVERY, CONF_ENABLED, + EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.discovery_flow import async_create_flow from homeassistant.components import network @@ -36,16 +36,35 @@ async def async_start_discovery(hass: HomeAssistant) -> None: LOGGER.info("PPPP camera discovery is disabled in configuration") return + # A single, reused instance so the "already discovered" set persists across + # iterations; a fresh instance each pass re-raised a discovery flow for every + # known camera on every interval. + discovery = PPPPDiscovery(hass) + async def discovery_loop() -> None: """Run discovery loop indefinitely.""" while True: try: - await PPPPDiscovery(hass).async_run_discovery() + await discovery.async_run_discovery() + except asyncio.CancelledError: + raise except Exception as err: LOGGER.error("Error during PPPP camera discovery: %s", err) await asyncio.sleep(interval) - hass.loop.create_task(discovery_loop()) + # Track the task so it can be cancelled on shutdown instead of running + # forever detached. + task = hass.async_create_background_task( + discovery_loop(), name="pppp_camera discovery" + ) + hass.data.setdefault(DOMAIN, {})["_discovery_task"] = task + + @callback + def _stop_discovery(_event) -> None: + if not task.done(): + task.cancel() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_discovery) class PPPPDiscovery: @@ -55,6 +74,8 @@ def __init__(self, hass: HomeAssistant) -> None: """Initialize the discovery class.""" self.hass = hass self.discovered_devices = set[str]() + # Only warn once about a misconfiguration, not every interval. + self._warned_no_ips = False async def async_run_discovery(self) -> None: """Run PPPP camera discovery periodically.""" @@ -72,10 +93,13 @@ async def async_run_discovery(self) -> None: ) if not discovery_ips: - LOGGER.warning( - "No discovery IPs found, PPPP camera discovery will not run." - ) + if not self._warned_no_ips: + LOGGER.warning( + "No discovery IPs found, PPPP camera discovery will not run." + ) + self._warned_no_ips = True return + self._warned_no_ips = False def device_callback(device: DeviceDescriptor): self._discovered_device_callback(device.addr, device.dev_id.dev_id) @@ -143,7 +167,7 @@ def is_valid_ip(ip_config: str) -> bool: LOGGER.error( "No valid IP addresses provided in configuration: %s", custom_ips ) - raise HomeAssistantError("No valid IP addresses provided in configuration") + return [] LOGGER.info("Using %d custom discovery IPs: %s", len(valid_ips), valid_ips) return valid_ips @@ -195,11 +219,11 @@ async def _async_get_broadcast_ips(self) -> List[str]: except Exception as err: LOGGER.error("Failed to get network adapters: %s", err) - raise HomeAssistantError(f"Failed to get broadcast IPs: {err}") + return [] if not broadcast_ips: LOGGER.error("No broadcast IPs found on any network adapters.") - raise HomeAssistantError("No broadcast IPs found on any network adapters.") + return [] LOGGER.info("Found %d broadcast IPs: %s", len(broadcast_ips), broadcast_ips) return broadcast_ips From 8208ff6890a23940a6fc5b3af3f505ae68c079ba Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:17:59 +0300 Subject: [PATCH 14/42] Add options flow for per-entry idle-disconnect delay Enable the previously commented-out options flow, exposing idle_disconnect_delay as a per-entry override (falling back to the YAML global). get_idle_disconnect_delay now accepts the config entry and prefers its options; PPPPDevice reads the per-entry value. The device's existing options-update listener reloads the entry so a change takes effect immediately. Also broaden async_validate_input so any connection failure (not just a timeout) surfaces as cannot_connect instead of aborting the flow, and add the options-step translations. Co-Authored-By: Claude Opus 4.8 --- custom_components/pppp_camera/config_flow.py | 96 +++++++++---------- .../pppp_camera/config_helpers.py | 13 ++- custom_components/pppp_camera/device.py | 2 +- .../pppp_camera/translations/en.json | 13 +++ 4 files changed, 71 insertions(+), 53 deletions(-) diff --git a/custom_components/pppp_camera/config_flow.py b/custom_components/pppp_camera/config_flow.py index c9c56fa..ad6d861 100644 --- a/custom_components/pppp_camera/config_flow.py +++ b/custom_components/pppp_camera/config_flow.py @@ -29,8 +29,13 @@ from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.helpers import selector -from .const import DOMAIN, LOGGER, SOURCE_DISCOVERY_CONFIRM -from .config_helpers import get_defaults +from .const import ( + DOMAIN, + LOGGER, + SOURCE_DISCOVERY_CONFIRM, + CONF_IDLE_DISCONNECT_DELAY, +) +from .config_helpers import get_defaults, get_idle_disconnect_delay @callback @@ -70,6 +75,11 @@ async def async_validate_input( except (TimeoutError, asyncio.TimeoutError): LOGGER.exception("Cannot connect to %s", user_input[CONF_HOST]) errors[field] = "cannot_connect" + except Exception: + # Any other failure (connection reset, bad value, auth) should surface + # as a friendly cannot_connect error instead of aborting the flow. + LOGGER.exception("Unexpected error connecting to %s", user_input[CONF_HOST]) + errors[field] = "cannot_connect" return errors, dev_descriptor.dev_id.dev_id if dev_descriptor else '' @@ -78,13 +88,13 @@ class PPPPCameraFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - # @staticmethod - # @callback - # def async_get_options_flow( - # config_entry: ConfigEntry, - # ) -> OptionsFlow: - # """Get the options flow for this handler.""" - # return PPPPCameraOptionsFlowHandler() + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: + """Get the options flow for this handler.""" + return PPPPCameraOptionsFlowHandler() async def async_step_integration_discovery( self, discovery_info: DiscoveryInfoType @@ -253,47 +263,33 @@ async def async_step_reconfigure( ) -# class PPPPCameraOptionsFlowHandler(OptionsFlow): -# """Handle PPPP Camera options.""" - -# async def async_step_init( -# self, user_input: dict[str, Any] | None = None -# ) -> ConfigFlowResult: -# """Manage PPPP Camera options.""" -# errors: dict[str, str] = {} - -# # Get defaults for username/password -# defaults = get_defaults(self.hass) -# default_username = defaults.get(CONF_USERNAME) -# default_password = defaults.get(CONF_PASSWORD) - -# if user_input is not None: -# errors, dev_id = await async_validate_input(self.hass, user_input) -# if not errors: -# for entry in self.hass.config_entries.async_entries(DOMAIN): -# if ( -# entry.entry_id != self.config_entry.entry_id -# and entry.options[CONF_HOST] == user_input[CONF_HOST] -# ): -# errors = {CONF_HOST: "already_configured"} - -# if not errors: -# return self.async_create_entry( -# title=dev_id, -# data={ -# CONF_HOST: user_input[CONF_HOST], -# CONF_USERNAME: user_input.get(CONF_USERNAME, default_username), -# CONF_PASSWORD: user_input.get(CONF_PASSWORD, default_password), -# }, -# ) -# else: -# user_input = {} - -# return self.async_show_form( -# step_id="init", -# data_schema=async_get_schema(user_input or self.config_entry.options), -# errors=errors, -# ) +class PPPPCameraOptionsFlowHandler(OptionsFlow): + """Handle PPPP Camera options (per-entry overrides).""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage PPPP Camera options.""" + if user_input is not None: + # Merge over the existing options so host/credentials are preserved. + options = {**self.config_entry.options, **user_input} + return self.async_create_entry(title="", data=options) + + current_delay = self.config_entry.options.get( + CONF_IDLE_DISCONNECT_DELAY, get_idle_disconnect_delay(self.hass) + ) + schema = vol.Schema( + { + vol.Optional( + CONF_IDLE_DISCONNECT_DELAY, default=current_delay + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=3600, step=1, mode=selector.NumberSelectorMode.BOX + ) + ), + } + ) + return self.async_show_form(step_id="init", data_schema=schema) class InvalidAuth(HomeAssistantError): diff --git a/custom_components/pppp_camera/config_helpers.py b/custom_components/pppp_camera/config_helpers.py index c60c01a..5d188bd 100644 --- a/custom_components/pppp_camera/config_helpers.py +++ b/custom_components/pppp_camera/config_helpers.py @@ -2,6 +2,7 @@ from typing import Any +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM @@ -29,8 +30,16 @@ def get_platform_config(hass: HomeAssistant) -> dict[str, Any]: """Get configuration for DOMAIN.""" return get_config(hass).get(CONF_PLATFORM, {}) -def get_idle_disconnect_delay(hass: HomeAssistant) -> int: - """Seconds to keep a camera session warm after the last operation.""" +def get_idle_disconnect_delay( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> int: + """Seconds to keep a camera session warm after the last operation. + + A per-entry options value (set via the options flow) overrides the YAML + global default when present. + """ + if config_entry is not None and CONF_IDLE_DISCONNECT_DELAY in config_entry.options: + return config_entry.options[CONF_IDLE_DISCONNECT_DELAY] return get_config(hass).get( CONF_IDLE_DISCONNECT_DELAY, DEFAULT_IDLE_DISCONNECT_DELAY ) diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index fbb0c33..fd72c86 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -41,7 +41,7 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: # warm for a short idle window so back-to-back operations reuse it. self._lock = asyncio.Lock() self._idle_unload_task: asyncio.Task | None = None - self._idle_disconnect_delay: int = get_idle_disconnect_delay(hass) + self._idle_disconnect_delay: int = get_idle_disconnect_delay(hass, config_entry) # Entities subscribe to these signals to refresh availability / stream state. self.signal_available = f"{DOMAIN}_{config_entry.entry_id}_available" diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index 8d9be75..31e34bf 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -53,5 +53,18 @@ "description": "{name}" } } + }, + "options": { + "step": { + "init": { + "title": "Camera options", + "data": { + "idle_disconnect_delay": "Idle disconnect delay (seconds)" + }, + "data_description": { + "idle_disconnect_delay": "How long to keep the camera session warm after the last operation. 0 disconnects immediately." + } + } + } } } \ No newline at end of file From aff6ba2786d949028f6d5bc780ade6fdb46ba3c0 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:21:52 +0300 Subject: [PATCH 15/42] Expose new binary capabilities in Home Assistant Surface the library's new binary-protocol features: - ptz_preset service (goto/set) on the camera entity, wired through a new PPPPDevice.async_ptz_preset helper to session.ptz_goto_preset/set_preset. - A sensor platform with battery, signal-strength, and uptime diagnostic sensors, created only when the camera reports the value (JSON batValue/ signal, binary batLevel). Added Platform.SENSOR to the entry setup. - A "Sync time" button (binary cameras) that sets the camera clock to Home Assistant local time via the new session.set_datetime, plus the PPPPDevice.async_sync_datetime helper. - services.yaml + translations for ptz/reboot/ptz_preset, the new sensors, and the sync-time button. Depends on the aiopppp release that adds these session methods; the manifest pin is bumped separately. Co-Authored-By: Claude Opus 4.8 --- custom_components/pppp_camera/__init__.py | 1 + custom_components/pppp_camera/button.py | 10 ++ custom_components/pppp_camera/camera.py | 20 ++- custom_components/pppp_camera/const.py | 5 + custom_components/pppp_camera/device.py | 22 ++++ custom_components/pppp_camera/sensor.py | 119 ++++++++++++++++++ custom_components/pppp_camera/services.yaml | 22 ++++ .../pppp_camera/translations/en.json | 48 +++++++ 8 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 custom_components/pppp_camera/sensor.py diff --git a/custom_components/pppp_camera/__init__.py b/custom_components/pppp_camera/__init__.py index e2d92ad..d6615cd 100644 --- a/custom_components/pppp_camera/__init__.py +++ b/custom_components/pppp_camera/__init__.py @@ -132,6 +132,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b Platform.BUTTON, Platform.LIGHT, Platform.SWITCH, + Platform.SENSOR, ] await hass.config_entries.async_forward_entry_setups(config_entry, device.platforms) diff --git a/custom_components/pppp_camera/button.py b/custom_components/pppp_camera/button.py index 219e773..da22d20 100644 --- a/custom_components/pppp_camera/button.py +++ b/custom_components/pppp_camera/button.py @@ -56,6 +56,16 @@ class PPPPButtonEntityDescription(ButtonEntityDescription): supported_fn=lambda device, hass: LAMP_STATE_PROPERTY["ir_lamp"] in device.device.properties and get_platform_config(hass)[CONF_LAMP] == Platform.BUTTON, icon="mdi:lightbulb-night" ), + PPPPButtonEntityDescription( + key="sync_time", + translation_key="sync_time", + press_fn=lambda device: device.async_sync_datetime, + press_data=None, + # Only binary cameras expose a set-time command in the library. + supported_fn=lambda device, _: not device.device.descriptor.is_json, + icon="mdi:clock-check", + entity_category=EntityCategory.CONFIG, + ), ) async def async_setup_entry( diff --git a/custom_components/pppp_camera/camera.py b/custom_components/pppp_camera/camera.py index bf401ca..ae31516 100644 --- a/custom_components/pppp_camera/camera.py +++ b/custom_components/pppp_camera/camera.py @@ -21,7 +21,9 @@ from homeassistant.util import uuid from .const import ( + ATTR_ACTION, ATTR_PAN, + ATTR_PRESET, ATTR_TILT, DIR_DOWN, DIR_LEFT, @@ -29,7 +31,10 @@ DIR_UP, DOMAIN, LOGGER, + PRESET_ACTION_GOTO, + PRESET_ACTION_SET, SERVICE_PTZ, + SERVICE_PTZ_PRESET, # ATTR_MOVE_MODE, # RELATIVE_MOVE, # CONTINUOUS_MOVE, @@ -37,7 +42,6 @@ # GOTOPRESET_MOVE, # STOP_MOVE, # ATTR_CONTINUOUS_DURATION, - # ATTR_PRESET, SERVICE_REBOOT, ) from .device import PPPPDevice @@ -79,6 +83,16 @@ async def async_setup_entry( }, "async_perform_ptz", ) + platform.async_register_entity_service( + SERVICE_PTZ_PRESET, + { + vol.Required(ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), + vol.Optional(ATTR_ACTION, default=PRESET_ACTION_GOTO): vol.In( + [PRESET_ACTION_GOTO, PRESET_ACTION_SET] + ), + }, + "async_perform_ptz_preset", + ) platform.async_register_entity_service( SERVICE_REBOOT, None, @@ -256,6 +270,10 @@ async def async_perform_ptz( # zoom, # ) + async def async_perform_ptz_preset(self, preset: int, action: str = PRESET_ACTION_GOTO) -> None: + """Go to or store a PTZ preset.""" + await self.device.async_ptz_preset(preset, action) + async def async_perform_reboot( self, ) -> None: diff --git a/custom_components/pppp_camera/const.py b/custom_components/pppp_camera/const.py index d98ab1e..62cd4ea 100644 --- a/custom_components/pppp_camera/const.py +++ b/custom_components/pppp_camera/const.py @@ -13,6 +13,10 @@ ATTR_MOVE_MODE = "move_mode" ATTR_CONTINUOUS_DURATION = "continuous_duration" ATTR_PRESET = "preset" +ATTR_ACTION = "action" + +PRESET_ACTION_GOTO = "goto" +PRESET_ACTION_SET = "set" CONTINUOUS_MOVE = "ContinuousMove" RELATIVE_MOVE = "RelativeMove" @@ -26,6 +30,7 @@ DIR_RIGHT = "RIGHT" SERVICE_PTZ = "ptz" +SERVICE_PTZ_PRESET = "ptz_preset" SERVICE_REBOOT = "reboot" SOURCE_DISCOVERY_CONFIRM = "discovery_confirm" diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index fd72c86..4442d48 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -14,7 +14,9 @@ Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import dt as dt_util from .config_helpers import get_idle_disconnect_delay from .const import DOMAIN @@ -190,6 +192,26 @@ async def async_reboot(self, data) -> None: async with self.ensure_connected(): await self.device.reboot() + async def async_ptz_preset(self, index: int, action: str) -> None: + """Go to or store a PTZ preset (binary-protocol cameras).""" + async with self.ensure_connected(): + session = self.device.session + if action == "set": + await session.ptz_set_preset(index) + else: + await session.ptz_goto_preset(index) + + async def async_sync_datetime(self, data=None) -> None: + """Set the camera clock to Home Assistant's local time.""" + async with self.ensure_connected(): + session = self.device.session + set_datetime = getattr(session, "set_datetime", None) + if set_datetime is None: + raise HomeAssistantError("This camera does not support setting the time") + now = dt_util.now() + offset = now.utcoffset() + await set_datetime(now, tz_seconds=int(offset.total_seconds()) if offset else 0) + @contextlib.asynccontextmanager async def ensure_connected(self): diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py new file mode 100644 index 0000000..5559a95 --- /dev/null +++ b/custom_components/pppp_camera/sensor.py @@ -0,0 +1,119 @@ +"""PPPP diagnostic sensors (battery, signal, uptime).""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .device import PPPPDevice +from .entity import PPPPBaseEntity + + +def _first(props: dict[str, Any], *keys: str) -> Any: + """Return the first present, non-None property among keys.""" + for key in keys: + value = props.get(key) + if value is not None: + return value + return None + + +@dataclass(frozen=True, kw_only=True) +class PPPPSensorEntityDescription(SensorEntityDescription): + """Describes a PPPP sensor entity.""" + + value_fn: Callable[[dict[str, Any]], Any] + supported_fn: Callable[[dict[str, Any]], bool] + + +SENSORS: tuple[PPPPSensorEntityDescription, ...] = ( + PPPPSensorEntityDescription( + key="battery", + translation_key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + # JSON cameras report batValue, binary cameras batLevel. + value_fn=lambda props: _first(props, "batValue", "batLevel"), + supported_fn=lambda props: _first(props, "batValue", "batLevel") is not None, + ), + PPPPSensorEntityDescription( + key="signal", + translation_key="signal", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda props: _first(props, "signal", "dbm"), + supported_fn=lambda props: _first(props, "signal", "dbm") is not None, + ), + PPPPSensorEntityDescription( + key="uptime", + translation_key="uptime", + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda props: props.get("uptime"), + supported_fn=lambda props: props.get("uptime") is not None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the PPPP sensor platform.""" + device: PPPPDevice = hass.data[DOMAIN][config_entry.unique_id] + props = device.device.properties + async_add_entities( + PPPPSensor(device, description) + for description in SENSORS + if description.supported_fn(props) + ) + + +class PPPPSensor(PPPPBaseEntity, SensorEntity): + """A PPPP diagnostic sensor. + + These cameras don't push updates, so values reflect the last-fetched + properties and refresh on the availability signal. + """ + + entity_description: PPPPSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, device: PPPPDevice, description: PPPPSensorEntityDescription + ) -> None: + """Initialize the sensor.""" + super().__init__(device) + self.entity_description = description + self._attr_unique_id = f"{self.device.dev_id}_{description.key}" + + @property + def native_value(self) -> Any: + """Return the current value from the camera's last-known properties.""" + return self.entity_description.value_fn(self.device.device.properties) diff --git a/custom_components/pppp_camera/services.yaml b/custom_components/pppp_camera/services.yaml index 3af9728..4bb6ce6 100644 --- a/custom_components/pppp_camera/services.yaml +++ b/custom_components/pppp_camera/services.yaml @@ -3,6 +3,28 @@ reboot: entity: integration: pppp_camera domain: camera +ptz_preset: + target: + entity: + integration: pppp_camera + domain: camera + fields: + preset: + required: true + example: 1 + selector: + number: + min: 0 + max: 255 + step: 1 + mode: box + action: + default: "goto" + selector: + select: + options: + - "goto" + - "set" ptz: target: entity: diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index 31e34bf..ebb3d2d 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -25,6 +25,20 @@ }, "ir_lamp": { "name": "IR Lamp" + }, + "sync_time": { + "name": "Sync time" + } + }, + "sensor": { + "battery": { + "name": "Battery" + }, + "signal": { + "name": "Signal strength" + }, + "uptime": { + "name": "Uptime" } }, "camera": { @@ -66,5 +80,39 @@ } } } + }, + "services": { + "reboot": { + "name": "Reboot", + "description": "Reboots the camera." + }, + "ptz": { + "name": "PTZ", + "description": "Pans or tilts the camera.", + "fields": { + "tilt": { + "name": "Tilt", + "description": "Tilt direction." + }, + "pan": { + "name": "Pan", + "description": "Pan direction." + } + } + }, + "ptz_preset": { + "name": "PTZ preset", + "description": "Moves to or stores a PTZ preset position.", + "fields": { + "preset": { + "name": "Preset", + "description": "Preset slot number (0-255)." + }, + "action": { + "name": "Action", + "description": "Whether to move to the preset or store the current position into it." + } + } + } } } \ No newline at end of file From 7550b68f9b8ce3ddbc3607162354c1e27324569e Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:23:21 +0300 Subject: [PATCH 16/42] Bump aiopppp pin to 0.3.0 and integration to 1.2.0 Require the aiopppp 0.3.0 release that adds the binary-protocol features this integration now exposes (PTZ presets, sensors, time sync). Also correct iot_class from local_push to local_polling: video is pulled and lamp state is assumed, nothing is pushed. Note: aiopppp 0.3.0 must be published to PyPI for this pin to resolve on a fresh install; the local editable version already matches for end-to-end testing. Co-Authored-By: Claude Opus 4.8 --- custom_components/pppp_camera/manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/pppp_camera/manifest.json b/custom_components/pppp_camera/manifest.json index b707691..64d6bdc 100644 --- a/custom_components/pppp_camera/manifest.json +++ b/custom_components/pppp_camera/manifest.json @@ -4,8 +4,8 @@ "codeowners": ["@devbis"], "config_flow": true, "dependencies": ["ffmpeg"], - "iot_class": "local_push", + "iot_class": "local_polling", "loggers": ["aiopppp"], - "requirements": ["aiopppp==0.2.3"], - "version": "1.1.2" + "requirements": ["aiopppp==0.3.0"], + "version": "1.2.0" } From 8bf3f97d1f6ea3f88f688c4e1b0ae0996b9b98c9 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:13:37 +0300 Subject: [PATCH 17/42] Fix battery sensor units and sync-time timezone sign - The battery sensor fed binary cameras' batLevel (millivolts) into a percentage sensor, showing readings like 4213%. Use aiopppp's derived batPercent (vendor-app thresholds; None when externally powered), with JSON cameras' batValue unchanged. - Sync-time passed the host's east-positive UTC offset as tz_seconds, but the camera stores seconds WEST of UTC -- every sync inverted the zone (UTC+3 became UTC-3). aiopppp>=0.4.0 computes the correct wire value when tz_seconds is unset, so pass only the timestamp. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/device.py | 8 +++++--- custom_components/pppp_camera/sensor.py | 9 ++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 4442d48..55f16fb 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -208,9 +208,11 @@ async def async_sync_datetime(self, data=None) -> None: set_datetime = getattr(session, "set_datetime", None) if set_datetime is None: raise HomeAssistantError("This camera does not support setting the time") - now = dt_util.now() - offset = now.utcoffset() - await set_datetime(now, tz_seconds=int(offset.total_seconds()) if offset else 0) + # The camera stores the timezone as seconds WEST of UTC; passing + # the east-positive offset here inverted every sync (UTC+3 became + # UTC-3). aiopppp>=0.4.0 computes the correct wire value itself + # when tz_seconds is left unset, so don't second-guess it. + await set_datetime(dt_util.now()) @contextlib.asynccontextmanager diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index 5559a95..937c4e8 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -52,9 +52,12 @@ class PPPPSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - # JSON cameras report batValue, binary cameras batLevel. - value_fn=lambda props: _first(props, "batValue", "batLevel"), - supported_fn=lambda props: _first(props, "batValue", "batLevel") is not None, + # JSON cameras report batValue (percent). Binary cameras report + # batLevel in MILLIVOLTS; aiopppp>=0.4.0 derives batPercent from it + # (None when externally powered / out of battery range), so use that + # -- feeding batLevel here showed readings like "4213%". + value_fn=lambda props: _first(props, "batValue", "batPercent"), + supported_fn=lambda props: _first(props, "batValue", "batPercent") is not None, ), PPPPSensorEntityDescription( key="signal", From 7a86cc764328b2ffbcc54ddadd8659bac96988b5 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:15:49 +0300 Subject: [PATCH 18/42] Add device-info diagnostic sensors New diagnostic sensors sourced from the camera status block (binary cameras): firmware version, power source (external/battery enum, from aiopppp's externalPower), SD card usage %, and timezone. All EntityCategory.DIAGNOSTIC; the noisier ones default-disabled. Also guard the existing uptime sensor against firmwares that report a negative (garbage) uptime. Translations added. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/sensor.py | 50 ++++++++++++++++++- .../pppp_camera/translations/en.json | 16 ++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index 937c4e8..ef01da7 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -78,7 +78,55 @@ class PPPPSensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda props: props.get("uptime"), - supported_fn=lambda props: props.get("uptime") is not None, + # Some firmwares report a garbage (negative) uptime; only expose it + # when it is a sane non-negative value. + supported_fn=lambda props: isinstance(props.get("uptime"), int) + and props["uptime"] >= 0, + ), + PPPPSensorEntityDescription( + key="firmware", + translation_key="firmware", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda props: props.get("mcuver"), + supported_fn=lambda props: bool(props.get("mcuver")), + ), + PPPPSensorEntityDescription( + key="power_source", + translation_key="power_source", + device_class=SensorDeviceClass.ENUM, + options=["external", "battery"], + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda props: ( + "external" if props.get("externalPower") else "battery" + ) + if "externalPower" in props + else None, + supported_fn=lambda props: "externalPower" in props, + ), + PPPPSensorEntityDescription( + key="sd_usage", + translation_key="sd_usage", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + # totalSize/usedSize are in the same (unknown) unit, so a ratio is + # meaningful even if the absolute unit isn't. + value_fn=lambda props: round( + 100 * props["usedSize"] / props["totalSize"] + ) + if props.get("totalSize") + else None, + supported_fn=lambda props: bool(props.get("totalSize")), + ), + PPPPSensorEntityDescription( + key="timezone", + translation_key="timezone", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda props: props.get("tz"), + supported_fn=lambda props: bool(props.get("tz")), ), ) diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index ebb3d2d..d6abf84 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -39,6 +39,22 @@ }, "uptime": { "name": "Uptime" + }, + "firmware": { + "name": "Firmware version" + }, + "power_source": { + "name": "Power source", + "state": { + "external": "External", + "battery": "Battery" + } + }, + "sd_usage": { + "name": "SD card usage" + }, + "timezone": { + "name": "Timezone" } }, "camera": { From 2a4d960c3fa38f0e0ffed06f233547e16de33bb6 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:20:29 +0300 Subject: [PATCH 19/42] Add two-way audio talk-back service + bump to aiopppp 0.4.0 New pppp_camera.talk service plays any media/TTS source to the camera speaker. ffmpeg (already a dependency) transcodes the media to 8 kHz mono 16-bit PCM; the session encodes/frames each 120 ms chunk and paces them to real time so the camera's jitter buffer isn't flooded. Guarded to cameras whose session exposes start_talk/send_audio (binary protocol). Media is picked via the standard media selector and resolved through media_source, so TTS and the media browser both work. Also bump the aiopppp pin to 0.4.0 (the session-side audio framing and all device-tested fixes) and the integration to 1.3.0. Live listen (incoming audio in the HA UI) needs WebRTC/RTSP and is left as follow-up; talk-back is the implementable half today. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/camera.py | 35 +++++++++++++- custom_components/pppp_camera/const.py | 3 ++ custom_components/pppp_camera/device.py | 46 +++++++++++++++++++ custom_components/pppp_camera/manifest.json | 4 +- custom_components/pppp_camera/services.yaml | 10 ++++ .../pppp_camera/translations/en.json | 10 ++++ 6 files changed, 105 insertions(+), 3 deletions(-) diff --git a/custom_components/pppp_camera/camera.py b/custom_components/pppp_camera/camera.py index ae31516..e8a0bcc 100644 --- a/custom_components/pppp_camera/camera.py +++ b/custom_components/pppp_camera/camera.py @@ -13,15 +13,20 @@ CameraEntityDescription, CameraEntityFeature, ) +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import uuid from .const import ( ATTR_ACTION, + ATTR_MEDIA, ATTR_PAN, ATTR_PRESET, ATTR_TILT, @@ -43,6 +48,7 @@ # STOP_MOVE, # ATTR_CONTINUOUS_DURATION, SERVICE_REBOOT, + SERVICE_TALK, ) from .device import PPPPDevice from .entity import PPPPBaseEntity @@ -50,6 +56,15 @@ TIMEOUT = 30 # BUFFER_SIZE = 102400 +# Value produced by the `media` selector in the talk service. +MEDIA_SELECTOR_SCHEMA = vol.Schema( + { + vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, + vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string, + }, + extra=vol.ALLOW_EXTRA, +) + async def async_setup_entry( hass: HomeAssistant, @@ -98,6 +113,11 @@ async def async_setup_entry( None, "async_perform_reboot", ) + platform.async_register_entity_service( + SERVICE_TALK, + {vol.Required(ATTR_MEDIA): MEDIA_SELECTOR_SCHEMA}, + "async_perform_talk", + ) async_add_entities([PPPPCamera(device)]) @@ -281,3 +301,16 @@ async def async_perform_reboot( # Go through the device helper so the session is (re)connected if it was # idle-closed; calling session.reboot() directly fails when disconnected. await self.device.async_reboot(None) + + async def async_perform_talk(self, media: dict) -> None: + """Play a media/TTS source to the camera speaker (talk-back).""" + from homeassistant.components import media_source + + media_id = media[ATTR_MEDIA_CONTENT_ID] + if media_source.is_media_source_id(media_id): + resolved = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) + media_id = resolved.url + media_id = media_source.async_process_play_media_url(self.hass, media_id) + await self.device.async_talk(media_id) diff --git a/custom_components/pppp_camera/const.py b/custom_components/pppp_camera/const.py index 62cd4ea..318b4e1 100644 --- a/custom_components/pppp_camera/const.py +++ b/custom_components/pppp_camera/const.py @@ -32,6 +32,9 @@ SERVICE_PTZ = "ptz" SERVICE_PTZ_PRESET = "ptz_preset" SERVICE_REBOOT = "reboot" +SERVICE_TALK = "talk" + +ATTR_MEDIA = "media" SOURCE_DISCOVERY_CONFIRM = "discovery_confirm" diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 55f16fb..bfa41ac 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -201,6 +201,52 @@ async def async_ptz_preset(self, index: int, action: str) -> None: else: await session.ptz_goto_preset(index) + async def async_talk(self, url: str) -> None: + """Play an audio URL to the camera speaker (talk-back). + + The camera wants 8 kHz mono G.711; ffmpeg transcodes arbitrary media + to raw 16-bit PCM at that rate and the session encodes/frames each + chunk. Chunks are paced in real time so the camera's small jitter + buffer isn't flooded. + """ + from homeassistant.components.ffmpeg import get_ffmpeg_manager + + async with self.ensure_connected(): + session = self.device.session + send_audio = getattr(session, "send_audio", None) + start_talk = getattr(session, "start_talk", None) + stop_talk = getattr(session, "stop_talk", None) + if not (send_audio and start_talk and stop_talk): + raise HomeAssistantError("This camera does not support talk-back") + + ffmpeg = get_ffmpeg_manager(self.hass) + proc = await asyncio.create_subprocess_exec( + ffmpeg.binary, "-nostdin", "-i", url, + "-f", "s16le", "-acodec", "pcm_s16le", "-ar", "8000", "-ac", "1", "pipe:1", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + + # 960 samples * 2 bytes = 120 ms per chunk at 8 kHz, matching the + # camera's own audio chunking. + chunk_bytes = 1920 + chunk_seconds = 0.12 + await start_talk() + try: + while True: + pcm = await proc.stdout.read(chunk_bytes) + if not pcm: + break + await send_audio(pcm) + # Pace by how much audio this chunk actually represents. + await asyncio.sleep(chunk_seconds * len(pcm) / chunk_bytes) + finally: + await stop_talk() + if proc.returncode is None: + with contextlib.suppress(ProcessLookupError): + proc.terminate() + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(proc.wait(), timeout=5) + async def async_sync_datetime(self, data=None) -> None: """Set the camera clock to Home Assistant's local time.""" async with self.ensure_connected(): diff --git a/custom_components/pppp_camera/manifest.json b/custom_components/pppp_camera/manifest.json index 64d6bdc..cf99a88 100644 --- a/custom_components/pppp_camera/manifest.json +++ b/custom_components/pppp_camera/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["ffmpeg"], "iot_class": "local_polling", "loggers": ["aiopppp"], - "requirements": ["aiopppp==0.3.0"], - "version": "1.2.0" + "requirements": ["aiopppp==0.4.0"], + "version": "1.3.0" } diff --git a/custom_components/pppp_camera/services.yaml b/custom_components/pppp_camera/services.yaml index 4bb6ce6..b3d00a1 100644 --- a/custom_components/pppp_camera/services.yaml +++ b/custom_components/pppp_camera/services.yaml @@ -3,6 +3,16 @@ reboot: entity: integration: pppp_camera domain: camera +talk: + target: + entity: + integration: pppp_camera + domain: camera + fields: + media: + required: true + selector: + media: ptz_preset: target: entity: diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index d6abf84..eaf0efe 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -102,6 +102,16 @@ "name": "Reboot", "description": "Reboots the camera." }, + "talk": { + "name": "Talk", + "description": "Plays an audio media or TTS source to the camera speaker (talk-back).", + "fields": { + "media": { + "name": "Media", + "description": "Audio media or TTS to play through the camera speaker." + } + } + }, "ptz": { "name": "PTZ", "description": "Pans or tilts the camera.", From 810a00475e20b98e6d868c2b06806e9898b714e9 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:46:23 +0300 Subject: [PATCH 20/42] Sensor cleanup, camera clock/SSID sensors, resolution select - Drop the firmware sensor: the version is already on the device registry entry (hw_version), so it was a duplicate. - Only create the power-source sensor when a real battery reading exists. Mains-only cameras leave a placeholder in batLevel (8000) and a zero powerSupply bit, which rendered as a confident, wrong 'Battery'. - The timezone sensor now disappears on firmwares that don't store a zone (aiopppp reports tz=None for those) instead of showing a bogus 'UTC-1' where the test UI correctly said 'device-managed'. - New camera-time sensor: the camera's own clock, projected forward from when it was read so a wrong clock or timezone stays visibly offset. It polls only to re-render; polling never contacts the camera, which accepts a single client. - New Wi-Fi network (SSID) sensor. - New resolution select (config category) backed by set_resolution, with the current value read from the camera at setup. Values that need their own commands (clock, SSID, resolution) are fetched once per connect into PPPPDevice.extra_info; each is optional and a camera that doesn't answer simply gets no entity. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/__init__.py | 1 + custom_components/pppp_camera/device.py | 70 +++++++++++++++++- custom_components/pppp_camera/select.py | 62 ++++++++++++++++ custom_components/pppp_camera/sensor.py | 72 +++++++++++++++---- .../pppp_camera/translations/en.json | 21 +++++- 5 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 custom_components/pppp_camera/select.py diff --git a/custom_components/pppp_camera/__init__.py b/custom_components/pppp_camera/__init__.py index d6615cd..5276809 100644 --- a/custom_components/pppp_camera/__init__.py +++ b/custom_components/pppp_camera/__init__.py @@ -133,6 +133,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b Platform.LIGHT, Platform.SWITCH, Platform.SENSOR, + Platform.SELECT, ] await hass.config_entries.async_forward_entry_setups(config_entry, device.platforms) diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index bfa41ac..6dfcb34 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -4,6 +4,8 @@ import asyncio import contextlib +import datetime as dt +import time import aiopppp from homeassistant.config_entries import ConfigEntry @@ -19,7 +21,7 @@ from homeassistant.util import dt as dt_util from .config_helpers import get_idle_disconnect_delay -from .const import DOMAIN +from .const import DOMAIN, LOGGER class PPPPDevice: @@ -34,6 +36,10 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self._original_options = dict(config_entry.options) self.available: bool = True self.info: dict = {} + # Values that aren't in the status block and need their own commands + # (clock, Wi-Fi, video params). Refreshed on connect; entities read the + # last-known values because these cameras never push updates. + self.extra_info: dict = {} self.platforms: list[Platform] = [] self._connected_num = 0 @@ -149,6 +155,7 @@ async def async_setup(self) -> None: async with self.ensure_connected(): self.info = self.device.properties + await self._async_fetch_extra_info() self.config_entry.async_on_unload( self.config_entry.add_update_listener(self._async_update_listener) @@ -161,6 +168,67 @@ async def async_stop(self, event=None): self._connected_num = 0 await self.device.close() + async def _async_fetch_extra_info(self) -> None: + """Read values that aren't part of the status block. + + Every one of these is optional: cameras answer a different subset + (and some answer none), so each failure is recorded as a missing key + rather than aborting setup. Must be called with a live connection. + """ + from aiopppp.packets import parse_datetime_block, parse_wifi_settings + + session = self.device.session + info: dict = {} + + if (get_datetime := getattr(session, "get_datetime", None)) is not None: + try: + decoded = parse_datetime_block(await get_datetime(timeout=4)) + if local := decoded.get("local"): + # Store the camera clock together with the monotonic + # instant it was read, so the sensor can project it + # forward instead of showing a frozen timestamp. + info["camera_time"] = dt.datetime.strptime(local, "%Y-%m-%d %H:%M:%S") + info["camera_time_read_at"] = time.monotonic() + except Exception as err: # noqa: BLE001 - optional, never fatal + LOGGER.debug("%s: datetime unavailable: %s", self.dev_id, err) + + if (get_wifi := getattr(session, "get_wifi_settings", None)) is not None: + try: + wifi = parse_wifi_settings(await get_wifi(timeout=4)) + if ssid := wifi.get("ssid"): + info["ssid"] = ssid + except Exception as err: # noqa: BLE001 - optional, never fatal + LOGGER.debug("%s: wifi settings unavailable: %s", self.dev_id, err) + + if (get_param := getattr(session, "get_video_param_value", None)) is not None: + try: + if (value := await get_param("resolution", timeout=4)) is not None: + info["resolution"] = value + except Exception as err: # noqa: BLE001 - optional, never fatal + LOGGER.debug("%s: resolution unavailable: %s", self.dev_id, err) + + self.extra_info = info + + async def async_refresh_extra_info(self) -> None: + """Re-read the extra info and notify entities.""" + async with self.ensure_connected(): + await self._async_fetch_extra_info() + async_dispatcher_send(self.hass, self.signal_available) + + async def async_set_resolution(self, value: str) -> None: + """Set the video resolution and remember the new value.""" + async with self.ensure_connected(): + session = self.device.session + set_resolution = getattr(session, "set_resolution", None) + if set_resolution is None: + raise HomeAssistantError("This camera does not support setting the resolution") + await set_resolution(value) + # The camera doesn't report a param change back, so record what we set; + # a later refresh overwrites it with whatever the camera reports. + from aiopppp.const import VideoResolution + + self.extra_info["resolution"] = VideoResolution[f"VIDEO_RESOLUTION_{value.upper()}"].value + async def async_white_light_toggle(self, data): """Turn on the white light.""" async with self.ensure_connected(): diff --git a/custom_components/pppp_camera/select.py b/custom_components/pppp_camera/select.py new file mode 100644 index 0000000..d27e378 --- /dev/null +++ b/custom_components/pppp_camera/select.py @@ -0,0 +1,62 @@ +"""Configuration selects for PPPP cameras.""" + +from __future__ import annotations + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .device import PPPPDevice +from .entity import PPPPBaseEntity + +# Order matches aiopppp's VideoResolution enum, so the index is the wire value. +RESOLUTION_OPTIONS = ["qvga", "vga", "hd", "fd", "ud"] + +RESOLUTION_DESCRIPTION = SelectEntityDescription( + key="resolution", + translation_key="resolution", + entity_category=EntityCategory.CONFIG, + options=RESOLUTION_OPTIONS, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the PPPP select platform.""" + device: PPPPDevice = hass.data[DOMAIN][config_entry.unique_id] + # Binary-protocol cameras only; JSON sessions don't implement video params. + if not hasattr(device.device.session, "set_resolution"): + return + async_add_entities([PPPPResolutionSelect(device)]) + + +class PPPPResolutionSelect(PPPPBaseEntity, SelectEntity): + """Video resolution of the camera stream.""" + + entity_description: SelectEntityDescription + _attr_has_entity_name = True + + def __init__(self, device: PPPPDevice) -> None: + """Initialize the select.""" + super().__init__(device) + self.entity_description = RESOLUTION_DESCRIPTION + self._attr_unique_id = f"{self.device.dev_id}_resolution" + + @property + def current_option(self) -> str | None: + """Return the resolution the camera last reported (or we last set).""" + value = self.device.extra_info.get("resolution") + if isinstance(value, int) and 0 <= value < len(RESOLUTION_OPTIONS): + return RESOLUTION_OPTIONS[value] + return None + + async def async_select_option(self, option: str) -> None: + """Change the camera's video resolution.""" + await self.device.async_set_resolution(option) + self.async_write_ha_state() diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index ef01da7..36df3cc 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -2,8 +2,10 @@ from __future__ import annotations +import time from collections.abc import Callable from dataclasses import dataclass +from datetime import timedelta from typing import Any from homeassistant.components.sensor import ( @@ -42,6 +44,25 @@ class PPPPSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[dict[str, Any]], Any] supported_fn: Callable[[dict[str, Any]], bool] + # Re-render on HA's poll interval. Only for values derived from elapsed + # time (the camera clock); polling never touches the camera itself. + poll: bool = False + + +def _camera_time(props: dict[str, Any]) -> str | None: + """The camera's own clock, advanced by the time since it was read. + + The cameras never push updates and are only queried on connect, so a raw + reading would sit frozen at whatever it said during setup. Projecting it + forward keeps it comparable with local time at a glance -- a camera whose + clock or timezone is wrong stays visibly offset. + """ + read = props.get("camera_time") + read_at = props.get("camera_time_read_at") + if read is None or read_at is None: + return None + elapsed = max(0.0, time.monotonic() - read_at) + return (read + timedelta(seconds=elapsed)).strftime("%Y-%m-%d %H:%M:%S") SENSORS: tuple[PPPPSensorEntityDescription, ...] = ( @@ -83,14 +104,6 @@ class PPPPSensorEntityDescription(SensorEntityDescription): supported_fn=lambda props: isinstance(props.get("uptime"), int) and props["uptime"] >= 0, ), - PPPPSensorEntityDescription( - key="firmware", - translation_key="firmware", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - value_fn=lambda props: props.get("mcuver"), - supported_fn=lambda props: bool(props.get("mcuver")), - ), PPPPSensorEntityDescription( key="power_source", translation_key="power_source", @@ -99,10 +112,12 @@ class PPPPSensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda props: ( "external" if props.get("externalPower") else "battery" - ) - if "externalPower" in props - else None, - supported_fn=lambda props: "externalPower" in props, + ), + # Mains-only cameras don't report power state at all: they leave a + # placeholder in batLevel (8000) and a zero powerSupply bit, which + # rendered as a confident (and wrong) "Battery". Only expose this + # where a real battery reading proves the fields are populated. + supported_fn=lambda props: _first(props, "batValue", "batPercent") is not None, ), PPPPSensorEntityDescription( key="sd_usage", @@ -125,9 +140,26 @@ class PPPPSensorEntityDescription(SensorEntityDescription): translation_key="timezone", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + # aiopppp reports None when the firmware doesn't actually store a + # timezone, so the sensor simply isn't created for those cameras. value_fn=lambda props: props.get("tz"), supported_fn=lambda props: bool(props.get("tz")), ), + PPPPSensorEntityDescription( + key="camera_time", + translation_key="camera_time", + entity_category=EntityCategory.DIAGNOSTIC, + poll=True, + value_fn=_camera_time, + supported_fn=lambda props: props.get("camera_time") is not None, + ), + PPPPSensorEntityDescription( + key="ssid", + translation_key="ssid", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda props: props.get("ssid"), + supported_fn=lambda props: bool(props.get("ssid")), + ), ) @@ -138,7 +170,7 @@ async def async_setup_entry( ) -> None: """Set up the PPPP sensor platform.""" device: PPPPDevice = hass.data[DOMAIN][config_entry.unique_id] - props = device.device.properties + props = _properties(device) async_add_entities( PPPPSensor(device, description) for description in SENSORS @@ -146,6 +178,11 @@ async def async_setup_entry( ) +def _properties(device: PPPPDevice) -> dict[str, Any]: + """Status-block properties plus the separately-fetched extras.""" + return {**device.device.properties, **device.extra_info} + + class PPPPSensor(PPPPBaseEntity, SensorEntity): """A PPPP diagnostic sensor. @@ -163,8 +200,15 @@ def __init__( super().__init__(device) self.entity_description = description self._attr_unique_id = f"{self.device.dev_id}_{description.key}" + # Overrides the base class's push-only default for time-derived values. + self._attr_should_poll = description.poll + + async def async_update(self) -> None: + """Re-render only. Polling must never reach out to the camera: these + devices accept a single client, so waking one for a diagnostic value + would fight with streaming.""" @property def native_value(self) -> Any: """Return the current value from the camera's last-known properties.""" - return self.entity_description.value_fn(self.device.device.properties) + return self.entity_description.value_fn(_properties(self.device)) diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index eaf0efe..f9043e0 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -40,9 +40,6 @@ "uptime": { "name": "Uptime" }, - "firmware": { - "name": "Firmware version" - }, "power_source": { "name": "Power source", "state": { @@ -55,6 +52,24 @@ }, "timezone": { "name": "Timezone" + }, + "camera_time": { + "name": "Camera time" + }, + "ssid": { + "name": "Wi-Fi network" + } + }, + "select": { + "resolution": { + "name": "Resolution", + "state": { + "qvga": "QVGA", + "vga": "VGA", + "hd": "HD", + "fd": "FD", + "ud": "UD" + } } }, "camera": { From 4c233951db3a6d239760bc2fab0607654d830344 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:46:27 +0300 Subject: [PATCH 21/42] Read the resolution only while the camera is streaming The select always showed QVGA because an idle camera answers VIDEOPARAM_GET with an all-zero parameter table -- confirmed on FTYC: resolution reads 0 before the stream starts and the real value (2 = HD) only once video is running. Decoding that zero as QVGA made every camera claim the lowest resolution regardless of its actual setting. The setup-time read is now skipped unless the stream is already up, and the select refreshes when the streaming signal fires, after a short settle delay (an immediate read still returns zeros). Until the camera reports a real value the option is left unknown rather than guessed. Reads stay rare on purpose: repeated VIDEOPARAM_GETs make these cameras stop answering (a third read in ~8s timed out during testing), so a failure keeps the last known value instead of retrying. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/device.py | 48 ++++++++++++++++++--- custom_components/pppp_camera/select.py | 57 +++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 6dfcb34..95cb787 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -200,15 +200,51 @@ async def _async_fetch_extra_info(self) -> None: except Exception as err: # noqa: BLE001 - optional, never fatal LOGGER.debug("%s: wifi settings unavailable: %s", self.dev_id, err) - if (get_param := getattr(session, "get_video_param_value", None)) is not None: - try: - if (value := await get_param("resolution", timeout=4)) is not None: - info["resolution"] = value - except Exception as err: # noqa: BLE001 - optional, never fatal - LOGGER.debug("%s: resolution unavailable: %s", self.dev_id, err) + # Video parameters are only populated while the stream runs: an idle + # camera answers VIDEOPARAM_GET with an all-zero table, which would + # read back as a confident (and wrong) QVGA. Keep whatever we already + # know instead, and refresh once streaming starts. + if (previous := self.extra_info.get("resolution")) is not None: + info["resolution"] = previous + if self._is_streaming: + info.pop("resolution", None) + if (value := await self._async_read_resolution()) is not None: + info["resolution"] = value self.extra_info = info + @property + def _is_streaming(self) -> bool: + """True while the camera is actively sending video.""" + return bool(self.device.is_connected and self.device.session.is_video_requested) + + async def _async_read_resolution(self) -> int | None: + """Read the current resolution, or None if the camera won't say. + + Repeated parameter reads are flaky on these cameras (they simply stop + answering), so a failure is never fatal -- the caller keeps the last + known value. + """ + session = self.device.session + get_param = getattr(session, "get_video_param_value", None) + if get_param is None: + return None + try: + return await get_param("resolution", timeout=4) + except Exception as err: # noqa: BLE001 - optional, never fatal + LOGGER.debug("%s: resolution unavailable: %s", self.dev_id, err) + return None + + async def async_refresh_resolution(self) -> bool: + """Re-read the resolution while streaming. True if the value changed.""" + if not self._is_streaming: + return False + value = await self._async_read_resolution() + if value is None or value == self.extra_info.get("resolution"): + return False + self.extra_info["resolution"] = value + return True + async def async_refresh_extra_info(self) -> None: """Re-read the extra info and notify entities.""" async with self.ensure_connected(): diff --git a/custom_components/pppp_camera/select.py b/custom_components/pppp_camera/select.py index d27e378..ed5a96d 100644 --- a/custom_components/pppp_camera/select.py +++ b/custom_components/pppp_camera/select.py @@ -2,19 +2,26 @@ from __future__ import annotations +import asyncio + from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, LOGGER from .device import PPPPDevice from .entity import PPPPBaseEntity # Order matches aiopppp's VideoResolution enum, so the index is the wire value. RESOLUTION_OPTIONS = ["qvga", "vga", "hd", "fd", "ud"] +# Grace period after the stream starts before the camera reports real video +# parameters. Measured on FTYC: an immediate read still returns zeros. +RESOLUTION_SETTLE_SECONDS = 2.5 + RESOLUTION_DESCRIPTION = SelectEntityDescription( key="resolution", translation_key="resolution", @@ -47,10 +54,54 @@ def __init__(self, device: PPPPDevice) -> None: super().__init__(device) self.entity_description = RESOLUTION_DESCRIPTION self._attr_unique_id = f"{self.device.dev_id}_resolution" + self._refresh_task: asyncio.Task | None = None + + async def async_added_to_hass(self) -> None: + """Subscribe to availability (base) and streaming-state changes.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, self.device.signal_streaming, self._handle_streaming + ) + ) + # The camera may already be streaming when this entity is added. + self._schedule_refresh() + + @callback + def _handle_streaming(self) -> None: + """The stream started or stopped; the reported value only exists while + it runs, so re-read it now.""" + self._schedule_refresh() + + @callback + def _schedule_refresh(self) -> None: + if self._refresh_task and not self._refresh_task.done(): + return + self._refresh_task = self.hass.async_create_task(self._async_refresh()) + self.async_on_remove(self._refresh_task.cancel) + + async def _async_refresh(self) -> None: + """Re-read the resolution, writing state only if it actually changed.""" + try: + # The camera needs a moment after the stream comes up before it + # reports real video parameters (it still answers with zeros + # immediately after LIVEVIDEO_START). + await asyncio.sleep(RESOLUTION_SETTLE_SECONDS) + if await self.device.async_refresh_resolution(): + self.async_write_ha_state() + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 - diagnostic only + LOGGER.debug("%s: resolution refresh failed: %s", self.device.dev_id, err) @property def current_option(self) -> str | None: - """Return the resolution the camera last reported (or we last set).""" + """Return the resolution the camera last reported (or we last set). + + None until the camera has actually reported one: an idle camera + answers with an all-zero parameter table, and trusting that would + show QVGA on every camera regardless of its real resolution. + """ value = self.device.extra_info.get("resolution") if isinstance(value, int) and 0 <= value < len(RESOLUTION_OPTIONS): return RESOLUTION_OPTIONS[value] From adb91ac3bdd9e1d533405eb2c546076be1f8e2dc Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:42:43 +0300 Subject: [PATCH 22/42] Poll the camera status so battery and power source can change The library reads the status block once, during session setup, and these cameras never push updates -- so battery, power source, uptime and SD usage were frozen at whatever they were when the session first connected. Plugging or unplugging a camera changed nothing in Home Assistant. PPPPDevice now refreshes the status (and the extra info: clock, SSID, resolution) on an interval and dispatches the availability signal so entities re-render. Verified against hardware: consecutive reads track the real battery voltage (4068 -> 4069 -> 4071 mV), confirming the values come from the camera rather than a cache. The interval is configurable per entry (options flow) and via YAML, defaulting to 5 minutes. Each poll opens a short session, so it is deliberately infrequent -- battery models can only sleep between connections -- and 0 disables it entirely. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/__init__.py | 6 ++ custom_components/pppp_camera/config_flow.py | 17 +++++- .../pppp_camera/config_helpers.py | 16 ++++++ custom_components/pppp_camera/const.py | 9 +++ custom_components/pppp_camera/device.py | 55 ++++++++++++++++++- .../pppp_camera/translations/en.json | 6 +- 6 files changed, 105 insertions(+), 4 deletions(-) diff --git a/custom_components/pppp_camera/__init__.py b/custom_components/pppp_camera/__init__.py index 5276809..1bb5c4a 100644 --- a/custom_components/pppp_camera/__init__.py +++ b/custom_components/pppp_camera/__init__.py @@ -29,7 +29,9 @@ CONF_INTERVAL, CONF_LAMP, CONF_IDLE_DISCONNECT_DELAY, + CONF_STATUS_POLL_INTERVAL, DEFAULT_IDLE_DISCONNECT_DELAY, + DEFAULT_STATUS_POLL_INTERVAL, ) @@ -64,6 +66,10 @@ CONF_IDLE_DISCONNECT_DELAY, default=DEFAULT_IDLE_DISCONNECT_DELAY, ): vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Optional( + CONF_STATUS_POLL_INTERVAL, + default=DEFAULT_STATUS_POLL_INTERVAL, + ): vol.All(vol.Coerce(int), vol.Range(min=0)), } ) }, diff --git a/custom_components/pppp_camera/config_flow.py b/custom_components/pppp_camera/config_flow.py index ad6d861..ebc07e8 100644 --- a/custom_components/pppp_camera/config_flow.py +++ b/custom_components/pppp_camera/config_flow.py @@ -34,8 +34,13 @@ LOGGER, SOURCE_DISCOVERY_CONFIRM, CONF_IDLE_DISCONNECT_DELAY, + CONF_STATUS_POLL_INTERVAL, +) +from .config_helpers import ( + get_defaults, + get_idle_disconnect_delay, + get_status_poll_interval, ) -from .config_helpers import get_defaults, get_idle_disconnect_delay @callback @@ -278,6 +283,9 @@ async def async_step_init( current_delay = self.config_entry.options.get( CONF_IDLE_DISCONNECT_DELAY, get_idle_disconnect_delay(self.hass) ) + current_poll = self.config_entry.options.get( + CONF_STATUS_POLL_INTERVAL, get_status_poll_interval(self.hass) + ) schema = vol.Schema( { vol.Optional( @@ -287,6 +295,13 @@ async def async_step_init( min=0, max=3600, step=1, mode=selector.NumberSelectorMode.BOX ) ), + vol.Optional( + CONF_STATUS_POLL_INTERVAL, default=current_poll + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=86400, step=1, mode=selector.NumberSelectorMode.BOX + ) + ), } ) return self.async_show_form(step_id="init", data_schema=schema) diff --git a/custom_components/pppp_camera/config_helpers.py b/custom_components/pppp_camera/config_helpers.py index 5d188bd..86a08a4 100644 --- a/custom_components/pppp_camera/config_helpers.py +++ b/custom_components/pppp_camera/config_helpers.py @@ -9,7 +9,9 @@ from .const import ( CONF_DEFAULTS, CONF_IDLE_DISCONNECT_DELAY, + CONF_STATUS_POLL_INTERVAL, DEFAULT_IDLE_DISCONNECT_DELAY, + DEFAULT_STATUS_POLL_INTERVAL, DOMAIN, ) @@ -43,3 +45,17 @@ def get_idle_disconnect_delay( return get_config(hass).get( CONF_IDLE_DISCONNECT_DELAY, DEFAULT_IDLE_DISCONNECT_DELAY ) + + +def get_status_poll_interval( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> int: + """Seconds between status refreshes (0 disables polling). + + A per-entry options value overrides the YAML global default when present. + """ + if config_entry is not None and CONF_STATUS_POLL_INTERVAL in config_entry.options: + return int(config_entry.options[CONF_STATUS_POLL_INTERVAL]) + return int( + get_config(hass).get(CONF_STATUS_POLL_INTERVAL, DEFAULT_STATUS_POLL_INTERVAL) + ) diff --git a/custom_components/pppp_camera/const.py b/custom_components/pppp_camera/const.py index 318b4e1..450549c 100644 --- a/custom_components/pppp_camera/const.py +++ b/custom_components/pppp_camera/const.py @@ -44,6 +44,7 @@ CONF_INTERVAL = "interval" CONF_LAMP = "lamp" CONF_IDLE_DISCONNECT_DELAY = "idle_disconnect_delay" +CONF_STATUS_POLL_INTERVAL = "status_poll_interval" # Maps a lamp entity key to the camera property that reports its on/off state. LAMP_STATE_PROPERTY = {"white_lamp": "lamp", "ir_lamp": "icut"} @@ -53,3 +54,11 @@ # the session and avoids tearing the connection down before a fire-and-forget # command has been delivered. 0 disconnects immediately. DEFAULT_IDLE_DISCONNECT_DELAY = 5 + +# How often to re-read the camera status block (battery, power source, uptime, +# SD usage). Nothing is pushed by these cameras, so without this the values +# stay frozen at whatever they were when the session first connected. +# +# Each poll opens a short session, so keep it infrequent: battery-powered +# models can only sleep between connections. 0 disables polling entirely. +DEFAULT_STATUS_POLL_INTERVAL = 300 diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 95cb787..bb39b4f 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import dt as dt_util -from .config_helpers import get_idle_disconnect_delay +from .config_helpers import get_idle_disconnect_delay, get_status_poll_interval from .const import DOMAIN, LOGGER @@ -50,6 +50,8 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self._lock = asyncio.Lock() self._idle_unload_task: asyncio.Task | None = None self._idle_disconnect_delay: int = get_idle_disconnect_delay(hass, config_entry) + self._status_poll_interval: int = get_status_poll_interval(hass, config_entry) + self._status_poll_task: asyncio.Task | None = None # Entities subscribe to these signals to refresh availability / stream state. self.signal_available = f"{DOMAIN}_{config_entry.entry_id}_available" @@ -157,12 +159,16 @@ async def async_setup(self) -> None: self.info = self.device.properties await self._async_fetch_extra_info() + self._start_status_poll() + self.config_entry.async_on_unload(self._stop_status_poll) + self.config_entry.async_on_unload( self.config_entry.add_update_listener(self._async_update_listener) ) async def async_stop(self, event=None): """Shut it all down.""" + self._stop_status_poll() async with self._lock: self._cancel_idle_unload() self._connected_num = 0 @@ -251,6 +257,53 @@ async def async_refresh_extra_info(self) -> None: await self._async_fetch_extra_info() async_dispatcher_send(self.hass, self.signal_available) + async def async_refresh_status(self) -> None: + """Re-read the status block (battery, power source, uptime, SD usage). + + The cameras never push updates and the library only reads the status + once, during session setup, so these values would otherwise stay frozen + at whatever they were when the session first connected. + """ + async with self.ensure_connected(): + session = self.device.session + get_status = getattr(session, "get_status", None) + if get_status is not None: + status = await get_status() + # Keep the auth flag the session recorded at setup; get_status() + # doesn't return it and entities shouldn't see it disappear. + status.setdefault("auth", session.dev_properties.get("auth")) + session.dev_properties = status + self.device.properties = status + self.info = status + await self._async_fetch_extra_info() + async_dispatcher_send(self.hass, self.signal_available) + + async def _status_poll_loop(self) -> None: + """Refresh the status on a fixed interval until cancelled.""" + while True: + try: + await asyncio.sleep(self._status_poll_interval) + await self.async_refresh_status() + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 - a poll failure is not fatal + # An unreachable camera is already reflected by availability; + # keep polling so the values recover on their own. + LOGGER.debug("%s: status poll failed: %s", self.dev_id, err) + + def _start_status_poll(self) -> None: + if not self._status_poll_interval: + LOGGER.debug("%s: status polling disabled", self.dev_id) + return + self._status_poll_task = self.hass.async_create_background_task( + self._status_poll_loop(), f"{DOMAIN}_status_poll_{self.dev_id}" + ) + + def _stop_status_poll(self) -> None: + if self._status_poll_task and not self._status_poll_task.done(): + self._status_poll_task.cancel() + self._status_poll_task = None + async def async_set_resolution(self, value: str) -> None: """Set the video resolution and remember the new value.""" async with self.ensure_connected(): diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index f9043e0..1e9107f 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -104,10 +104,12 @@ "init": { "title": "Camera options", "data": { - "idle_disconnect_delay": "Idle disconnect delay (seconds)" + "idle_disconnect_delay": "Idle disconnect delay (seconds)", + "status_poll_interval": "Status poll interval (seconds)" }, "data_description": { - "idle_disconnect_delay": "How long to keep the camera session warm after the last operation. 0 disconnects immediately." + "idle_disconnect_delay": "How long to keep the camera session warm after the last operation. 0 disconnects immediately.", + "status_poll_interval": "How often to refresh battery, power source and uptime. Each poll briefly connects to the camera, so keep it infrequent on battery-powered models. 0 disables polling." } } } From 737e363d11ab28382a3bfdfc5a66065b27b14f4d Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:56:04 +0300 Subject: [PATCH 23/42] Poll only what live entities actually need Status polling ran unconditionally, so a mains-powered camera with no SD card was woken every 5 minutes to re-read values that cannot change, and disabling the battery sensors changed nothing. Entities now claim the poll group their value comes from when they are added to Home Assistant. Home Assistant never adds disabled entities, so the claim count is an accurate picture of what is actually being displayed: a camera whose battery and SD sensors do not exist (or are switched off) is never contacted at all. Splits the single poll into two groups with their own intervals: status (battery, uptime, SD usage) -- default 5 min info (camera clock, Wi-Fi SSID) -- default 1 hour, since an SSID only changes on re-provisioning The timezone sensor claims no group: it is static, so refreshing it would never be worth a round trip. Both intervals are configurable per entry and via YAML, and 0 still disables a group outright. Verified by simulation over real status blocks: the FTYC (battery, no SD) polls status+info, a mains PTZA with no SD polls nothing, and fitting an SD card brings status polling back for that camera alone. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/__init__.py | 3 + custom_components/pppp_camera/config_flow.py | 12 +++ .../pppp_camera/config_helpers.py | 32 +++++-- custom_components/pppp_camera/const.py | 19 ++++- custom_components/pppp_camera/device.py | 85 ++++++++++++++----- custom_components/pppp_camera/sensor.py | 23 ++++- .../pppp_camera/translations/en.json | 8 +- 7 files changed, 145 insertions(+), 37 deletions(-) diff --git a/custom_components/pppp_camera/__init__.py b/custom_components/pppp_camera/__init__.py index 1bb5c4a..bb1acf2 100644 --- a/custom_components/pppp_camera/__init__.py +++ b/custom_components/pppp_camera/__init__.py @@ -30,8 +30,10 @@ CONF_LAMP, CONF_IDLE_DISCONNECT_DELAY, CONF_STATUS_POLL_INTERVAL, + CONF_INFO_POLL_INTERVAL, DEFAULT_IDLE_DISCONNECT_DELAY, DEFAULT_STATUS_POLL_INTERVAL, + DEFAULT_INFO_POLL_INTERVAL, ) @@ -68,6 +70,7 @@ ): vol.All(vol.Coerce(int), vol.Range(min=0)), vol.Optional( CONF_STATUS_POLL_INTERVAL, + CONF_INFO_POLL_INTERVAL, default=DEFAULT_STATUS_POLL_INTERVAL, ): vol.All(vol.Coerce(int), vol.Range(min=0)), } diff --git a/custom_components/pppp_camera/config_flow.py b/custom_components/pppp_camera/config_flow.py index ebc07e8..afb5d43 100644 --- a/custom_components/pppp_camera/config_flow.py +++ b/custom_components/pppp_camera/config_flow.py @@ -35,11 +35,13 @@ SOURCE_DISCOVERY_CONFIRM, CONF_IDLE_DISCONNECT_DELAY, CONF_STATUS_POLL_INTERVAL, + CONF_INFO_POLL_INTERVAL, ) from .config_helpers import ( get_defaults, get_idle_disconnect_delay, get_status_poll_interval, + get_info_poll_interval, ) @@ -286,6 +288,9 @@ async def async_step_init( current_poll = self.config_entry.options.get( CONF_STATUS_POLL_INTERVAL, get_status_poll_interval(self.hass) ) + current_info_poll = self.config_entry.options.get( + CONF_INFO_POLL_INTERVAL, get_info_poll_interval(self.hass) + ) schema = vol.Schema( { vol.Optional( @@ -302,6 +307,13 @@ async def async_step_init( min=0, max=86400, step=1, mode=selector.NumberSelectorMode.BOX ) ), + vol.Optional( + CONF_INFO_POLL_INTERVAL, default=current_info_poll + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=86400, step=1, mode=selector.NumberSelectorMode.BOX + ) + ), } ) return self.async_show_form(step_id="init", data_schema=schema) diff --git a/custom_components/pppp_camera/config_helpers.py b/custom_components/pppp_camera/config_helpers.py index 86a08a4..37b8c71 100644 --- a/custom_components/pppp_camera/config_helpers.py +++ b/custom_components/pppp_camera/config_helpers.py @@ -9,8 +9,10 @@ from .const import ( CONF_DEFAULTS, CONF_IDLE_DISCONNECT_DELAY, + CONF_INFO_POLL_INTERVAL, CONF_STATUS_POLL_INTERVAL, DEFAULT_IDLE_DISCONNECT_DELAY, + DEFAULT_INFO_POLL_INTERVAL, DEFAULT_STATUS_POLL_INTERVAL, DOMAIN, ) @@ -47,15 +49,31 @@ def get_idle_disconnect_delay( ) +def _get_interval( + hass: HomeAssistant, + config_entry: ConfigEntry | None, + option: str, + default: int, +) -> int: + """Read an interval option, preferring the per-entry value.""" + if config_entry is not None and option in config_entry.options: + return int(config_entry.options[option]) + return int(get_config(hass).get(option, default)) + + def get_status_poll_interval( hass: HomeAssistant, config_entry: ConfigEntry | None = None ) -> int: - """Seconds between status refreshes (0 disables polling). + """Seconds between status-block refreshes (0 disables polling).""" + return _get_interval( + hass, config_entry, CONF_STATUS_POLL_INTERVAL, DEFAULT_STATUS_POLL_INTERVAL + ) - A per-entry options value overrides the YAML global default when present. - """ - if config_entry is not None and CONF_STATUS_POLL_INTERVAL in config_entry.options: - return int(config_entry.options[CONF_STATUS_POLL_INTERVAL]) - return int( - get_config(hass).get(CONF_STATUS_POLL_INTERVAL, DEFAULT_STATUS_POLL_INTERVAL) + +def get_info_poll_interval( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> int: + """Seconds between clock/SSID refreshes (0 disables polling).""" + return _get_interval( + hass, config_entry, CONF_INFO_POLL_INTERVAL, DEFAULT_INFO_POLL_INTERVAL ) diff --git a/custom_components/pppp_camera/const.py b/custom_components/pppp_camera/const.py index 450549c..af2d5a8 100644 --- a/custom_components/pppp_camera/const.py +++ b/custom_components/pppp_camera/const.py @@ -45,6 +45,14 @@ CONF_LAMP = "lamp" CONF_IDLE_DISCONNECT_DELAY = "idle_disconnect_delay" CONF_STATUS_POLL_INTERVAL = "status_poll_interval" +CONF_INFO_POLL_INTERVAL = "info_poll_interval" + +# Poll groups. Entities register the group they read from when they are added +# to Home Assistant, and a group is only polled while something is actually +# using it -- a camera with no battery and no SD card never gets a status +# poll, and disabling those entities stops it too. +POLL_GROUP_STATUS = "status" +POLL_GROUP_INFO = "info" # Maps a lamp entity key to the camera property that reports its on/off state. LAMP_STATE_PROPERTY = {"white_lamp": "lamp", "ir_lamp": "icut"} @@ -55,10 +63,15 @@ # command has been delivered. 0 disconnects immediately. DEFAULT_IDLE_DISCONNECT_DELAY = 5 -# How often to re-read the camera status block (battery, power source, uptime, -# SD usage). Nothing is pushed by these cameras, so without this the values -# stay frozen at whatever they were when the session first connected. +# How often to re-read the camera status block (battery, uptime, SD usage). +# Nothing is pushed by these cameras, so without this the values stay frozen +# at whatever they were when the session first connected. # # Each poll opens a short session, so keep it infrequent: battery-powered # models can only sleep between connections. 0 disables polling entirely. DEFAULT_STATUS_POLL_INTERVAL = 300 + +# How often to re-read values that need their own commands (camera clock, +# Wi-Fi SSID). These barely change -- the SSID only when the camera is +# re-provisioned -- so this is deliberately much slower than the status poll. +DEFAULT_INFO_POLL_INTERVAL = 3600 diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index bb39b4f..eb06228 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -6,6 +6,7 @@ import contextlib import datetime as dt import time +from collections.abc import Callable import aiopppp from homeassistant.config_entries import ConfigEntry @@ -20,8 +21,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import dt as dt_util -from .config_helpers import get_idle_disconnect_delay, get_status_poll_interval -from .const import DOMAIN, LOGGER +from .config_helpers import ( + get_idle_disconnect_delay, + get_info_poll_interval, + get_status_poll_interval, +) +from .const import DOMAIN, LOGGER, POLL_GROUP_INFO, POLL_GROUP_STATUS class PPPPDevice: @@ -51,7 +56,10 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self._idle_unload_task: asyncio.Task | None = None self._idle_disconnect_delay: int = get_idle_disconnect_delay(hass, config_entry) self._status_poll_interval: int = get_status_poll_interval(hass, config_entry) - self._status_poll_task: asyncio.Task | None = None + self._info_poll_interval: int = get_info_poll_interval(hass, config_entry) + self._poll_tasks: list[asyncio.Task] = [] + # Live entity count per poll group; a group with none is never polled. + self._poll_consumers: dict[str, int] = {} # Entities subscribe to these signals to refresh availability / stream state. self.signal_available = f"{DOMAIN}_{config_entry.entry_id}_available" @@ -159,8 +167,8 @@ async def async_setup(self) -> None: self.info = self.device.properties await self._async_fetch_extra_info() - self._start_status_poll() - self.config_entry.async_on_unload(self._stop_status_poll) + self._start_polling() + self.config_entry.async_on_unload(self._stop_polling) self.config_entry.async_on_unload( self.config_entry.add_update_listener(self._async_update_listener) @@ -168,7 +176,7 @@ async def async_setup(self) -> None: async def async_stop(self, event=None): """Shut it all down.""" - self._stop_status_poll() + self._stop_polling() async with self._lock: self._cancel_idle_unload() self._connected_num = 0 @@ -278,31 +286,62 @@ async def async_refresh_status(self) -> None: await self._async_fetch_extra_info() async_dispatcher_send(self.hass, self.signal_available) - async def _status_poll_loop(self) -> None: - """Refresh the status on a fixed interval until cancelled.""" + @callback + def register_poll_group(self, group: str) -> Callable[[], None]: + """Declare that a live entity reads from `group`, and return the + function that releases it again. + + Home Assistant never adds disabled entities, so counting registrations + is enough to know whether anything actually needs the data: a camera + with no battery and no SD card creates none of those sensors, and + disabling them releases the group. Either way the group stops being + polled. + """ + self._poll_consumers[group] = self._poll_consumers.get(group, 0) + 1 + + @callback + def release() -> None: + self._poll_consumers[group] = max(0, self._poll_consumers.get(group, 0) - 1) + + return release + + async def _poll_loop(self, group: str, interval: int, refresh) -> None: + """Refresh `group` every `interval` seconds while it has consumers.""" while True: try: - await asyncio.sleep(self._status_poll_interval) - await self.async_refresh_status() + await asyncio.sleep(interval) + if not self._poll_consumers.get(group): + # Nothing is using this data; skip the round trip entirely + # rather than waking the camera for values nobody reads. + continue + await refresh() except asyncio.CancelledError: raise except Exception as err: # noqa: BLE001 - a poll failure is not fatal # An unreachable camera is already reflected by availability; # keep polling so the values recover on their own. - LOGGER.debug("%s: status poll failed: %s", self.dev_id, err) - - def _start_status_poll(self) -> None: - if not self._status_poll_interval: - LOGGER.debug("%s: status polling disabled", self.dev_id) - return - self._status_poll_task = self.hass.async_create_background_task( - self._status_poll_loop(), f"{DOMAIN}_status_poll_{self.dev_id}" - ) + LOGGER.debug("%s: %s poll failed: %s", self.dev_id, group, err) + + def _start_polling(self) -> None: + for group, interval, refresh in ( + (POLL_GROUP_STATUS, self._status_poll_interval, self.async_refresh_status), + (POLL_GROUP_INFO, self._info_poll_interval, self.async_refresh_extra_info), + ): + if not interval: + LOGGER.debug("%s: %s polling disabled", self.dev_id, group) + continue + self._poll_tasks.append( + self.hass.async_create_background_task( + self._poll_loop(group, interval, refresh), + f"{DOMAIN}_{group}_poll_{self.dev_id}", + ) + ) - def _stop_status_poll(self) -> None: - if self._status_poll_task and not self._status_poll_task.done(): - self._status_poll_task.cancel() - self._status_poll_task = None + def _stop_polling(self) -> None: + for task in self._poll_tasks: + if not task.done(): + task.cancel() + self._poll_tasks = [] async def async_set_resolution(self, value: str) -> None: """Set the video resolution and remember the new value.""" diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index 36df3cc..951cf2a 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, POLL_GROUP_INFO, POLL_GROUP_STATUS from .device import PPPPDevice from .entity import PPPPBaseEntity @@ -47,6 +47,9 @@ class PPPPSensorEntityDescription(SensorEntityDescription): # Re-render on HA's poll interval. Only for values derived from elapsed # time (the camera clock); polling never touches the camera itself. poll: bool = False + # Which device poll group keeps this value fresh. None for values that + # never change (timezone), so they never cause a camera round trip. + poll_group: str | None = None def _camera_time(props: dict[str, Any]) -> str | None: @@ -69,6 +72,7 @@ def _camera_time(props: dict[str, Any]) -> str | None: PPPPSensorEntityDescription( key="battery", translation_key="battery", + poll_group=POLL_GROUP_STATUS, device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -94,6 +98,7 @@ def _camera_time(props: dict[str, Any]) -> str | None: PPPPSensorEntityDescription( key="uptime", translation_key="uptime", + poll_group=POLL_GROUP_STATUS, native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -107,6 +112,7 @@ def _camera_time(props: dict[str, Any]) -> str | None: PPPPSensorEntityDescription( key="power_source", translation_key="power_source", + poll_group=POLL_GROUP_STATUS, device_class=SensorDeviceClass.ENUM, options=["external", "battery"], entity_category=EntityCategory.DIAGNOSTIC, @@ -122,6 +128,7 @@ def _camera_time(props: dict[str, Any]) -> str | None: PPPPSensorEntityDescription( key="sd_usage", translation_key="sd_usage", + poll_group=POLL_GROUP_STATUS, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -148,6 +155,7 @@ def _camera_time(props: dict[str, Any]) -> str | None: PPPPSensorEntityDescription( key="camera_time", translation_key="camera_time", + poll_group=POLL_GROUP_INFO, entity_category=EntityCategory.DIAGNOSTIC, poll=True, value_fn=_camera_time, @@ -156,6 +164,7 @@ def _camera_time(props: dict[str, Any]) -> str | None: PPPPSensorEntityDescription( key="ssid", translation_key="ssid", + poll_group=POLL_GROUP_INFO, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda props: props.get("ssid"), supported_fn=lambda props: bool(props.get("ssid")), @@ -203,6 +212,18 @@ def __init__( # Overrides the base class's push-only default for time-derived values. self._attr_should_poll = description.poll + async def async_added_to_hass(self) -> None: + """Claim the poll group this sensor's value comes from. + + Only enabled entities are ever added, so claiming here is what keeps + the camera from being polled for data nobody is displaying. + """ + await super().async_added_to_hass() + if self.entity_description.poll_group: + self.async_on_remove( + self.device.register_poll_group(self.entity_description.poll_group) + ) + async def async_update(self) -> None: """Re-render only. Polling must never reach out to the camera: these devices accept a single client, so waking one for a diagnostic value diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index 1e9107f..36276a4 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -105,11 +105,13 @@ "title": "Camera options", "data": { "idle_disconnect_delay": "Idle disconnect delay (seconds)", - "status_poll_interval": "Status poll interval (seconds)" + "status_poll_interval": "Status poll interval (seconds)", + "info_poll_interval": "Device info poll interval (seconds)" }, "data_description": { "idle_disconnect_delay": "How long to keep the camera session warm after the last operation. 0 disconnects immediately.", - "status_poll_interval": "How often to refresh battery, power source and uptime. Each poll briefly connects to the camera, so keep it infrequent on battery-powered models. 0 disables polling." + "status_poll_interval": "How often to refresh battery, uptime and SD usage. Only polled while at least one of those entities is enabled, so a camera without a battery or SD card is never contacted. 0 disables polling.", + "info_poll_interval": "How often to refresh the camera clock and Wi-Fi network. These rarely change, so this can be long. 0 disables polling." } } } @@ -158,4 +160,4 @@ } } } -} \ No newline at end of file +} From cc5587247ab1ff46700c790a1423ac7fa3746a09 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:05:34 +0300 Subject: [PATCH 24/42] Refresh the timezone from the clock response it already contains get_datetime() -- the call already made for the camera-clock sensor -- returns the timezone alongside the time, and it was being discarded in favour of the copy in the status block. Keeping it lets the timezone entity refresh whenever the info group runs, at no extra cost. The timezone sensor still claims no poll group: a static value should ride along with a poll that is happening anyway, never keep one alive by itself. Cameras that manage their own timezone report a placeholder in this response rather than a real zone, so only a genuine zone is taken and those cameras still get no timezone entity. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/device.py | 7 +++++++ custom_components/pppp_camera/sensor.py | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index eb06228..627137f 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -203,6 +203,13 @@ async def _async_fetch_extra_info(self) -> None: # forward instead of showing a frozen timestamp. info["camera_time"] = dt.datetime.strptime(local, "%Y-%m-%d %H:%M:%S") info["camera_time_read_at"] = time.monotonic() + # This same response carries the timezone, so keeping it costs + # nothing and lets the timezone entity refresh along with the + # clock. Only a real zone: firmwares that manage their own + # report a placeholder here, and the status block already + # reports None for those. + if decoded.get("layout") == "utc+tz" and (tz := decoded.get("tz")): + info["tz"] = tz except Exception as err: # noqa: BLE001 - optional, never fatal LOGGER.debug("%s: datetime unavailable: %s", self.dev_id, err) diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index 951cf2a..7122a8a 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -147,6 +147,10 @@ def _camera_time(props: dict[str, Any]) -> str | None: translation_key="timezone", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + # No poll_group on purpose. The clock response already carries the + # timezone, so this refreshes for free whenever the info group runs -- + # but a static value should never keep that poll alive by itself. + # # aiopppp reports None when the firmware doesn't actually store a # timezone, so the sensor simply isn't created for those cameras. value_fn=lambda props: props.get("tz"), From c8640d606638582891b7ad73419849f5518be91b Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:30:00 +0300 Subject: [PATCH 25/42] Re-read the camera clock after syncing it The camera-time sensor projects its last reading forward, and nothing re-read the clock after a sync. A camera whose clock had drifted (or reset) kept counting up from the stale pre-sync value, so pressing Sync appeared to do nothing -- the sensor sat in 2024 ticking one second per second while the camera itself was correct. Syncing now re-reads the clock and notifies entities. set_datetime() already issues a read of its own and these cameras ignore commands that arrive immediately after another, so the read-back waits for the device to settle; if it still doesn't answer, the value just written is used rather than leaving the old one counting up, and the next info poll replaces it with a genuine reading. Also stops a transient timeout from blanking these sensors: _async_fetch_extra_info() rebuilt its dict from scratch, so one dropped response erased the last-known clock, SSID or resolution. It now starts from the current values and overwrites only what it successfully reads. Verified against the device: a stale 2024 base is corrected to within a second of host time after one sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/const.py | 5 +++ custom_components/pppp_camera/device.py | 51 +++++++++++++++++++------ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/custom_components/pppp_camera/const.py b/custom_components/pppp_camera/const.py index af2d5a8..552b43f 100644 --- a/custom_components/pppp_camera/const.py +++ b/custom_components/pppp_camera/const.py @@ -75,3 +75,8 @@ # Wi-Fi SSID). These barely change -- the SSID only when the camera is # re-provisioned -- so this is deliberately much slower than the status poll. DEFAULT_INFO_POLL_INTERVAL = 3600 + +# Pause between setting the camera clock and reading it back. These cameras +# ignore commands that arrive immediately after another, and set_datetime() +# already performs a read of its own. +SYNC_READBACK_DELAY = 2.0 diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 627137f..48a62dc 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -26,7 +26,13 @@ get_info_poll_interval, get_status_poll_interval, ) -from .const import DOMAIN, LOGGER, POLL_GROUP_INFO, POLL_GROUP_STATUS +from .const import ( + DOMAIN, + LOGGER, + POLL_GROUP_INFO, + POLL_GROUP_STATUS, + SYNC_READBACK_DELAY, +) class PPPPDevice: @@ -186,13 +192,17 @@ async def _async_fetch_extra_info(self) -> None: """Read values that aren't part of the status block. Every one of these is optional: cameras answer a different subset - (and some answer none), so each failure is recorded as a missing key - rather than aborting setup. Must be called with a live connection. + (and some answer none), so each failure leaves the previous value in + place rather than aborting setup. Must be called with a live + connection. """ from aiopppp.packets import parse_datetime_block, parse_wifi_settings session = self.device.session - info: dict = {} + # Start from what we already know: these cameras drop commands issued + # in quick succession, and a single timeout must not blank a sensor + # that was reading fine a moment ago. + info: dict = dict(self.extra_info) if (get_datetime := getattr(session, "get_datetime", None)) is not None: try: @@ -225,12 +235,8 @@ async def _async_fetch_extra_info(self) -> None: # camera answers VIDEOPARAM_GET with an all-zero table, which would # read back as a confident (and wrong) QVGA. Keep whatever we already # know instead, and refresh once streaming starts. - if (previous := self.extra_info.get("resolution")) is not None: - info["resolution"] = previous - if self._is_streaming: - info.pop("resolution", None) - if (value := await self._async_read_resolution()) is not None: - info["resolution"] = value + if self._is_streaming and (value := await self._async_read_resolution()) is not None: + info["resolution"] = value self.extra_info = info @@ -461,7 +467,30 @@ async def async_sync_datetime(self, data=None) -> None: # the east-positive offset here inverted every sync (UTC+3 became # UTC-3). aiopppp>=0.4.0 computes the correct wire value itself # when tz_seconds is left unset, so don't second-guess it. - await set_datetime(dt_util.now()) + now = dt_util.now() + await set_datetime(now) + + # Re-read the clock we just set. The camera-time sensor projects + # its last reading forward, so without this it would keep counting + # up from the pre-sync value -- showing the old (wrong) time as if + # the sync had done nothing, until the next info poll an hour on. + # + # set_datetime() already issued its own read, and these cameras + # drop commands that arrive back-to-back, so let it settle first. + read_at = self.extra_info.get("camera_time_read_at") + await asyncio.sleep(SYNC_READBACK_DELAY) + await self._async_fetch_extra_info() + + if self.extra_info.get("camera_time_read_at") == read_at: + # The read-back didn't land. We still know what we just wrote, + # and anything is better than continuing to count up from the + # pre-sync value; the next info poll replaces this with a + # genuine reading. + LOGGER.debug("%s: clock read-back after sync failed; assuming the " + "value just written", self.dev_id) + self.extra_info["camera_time"] = now.replace(tzinfo=None) + self.extra_info["camera_time_read_at"] = time.monotonic() + async_dispatcher_send(self.hass, self.signal_available) @contextlib.asynccontextmanager From 40230e7cdedde4ce2b2ab1e408c246c9bce177e9 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:51:43 +0300 Subject: [PATCH 26/42] Report the camera clock as an offset instead of a ticking time Replaces the camera-time sensor with a clock-offset sensor: how many seconds the camera is ahead (+) or behind (-) Home Assistant, with the reading it came from kept as a camera_time attribute. The offset answers the question the sensor exists for -- is the clock right? -- without any of the drawbacks of showing the time itself. It holds still between readings, so it needs no per-30s re-render and can never drift into a plausible-looking lie the way a locally-advanced clock did when the camera's clock had reset. Home Assistant's timestamp device class doesn't help here: it renders a fixed instant as relative time ('2 minutes ago'), which reads as wrong for a clock. The offset is measured against whichever notion of time the firmware keeps, so a camera on a different timezone doesn't look broken: timestamps that are genuinely UTC are compared as instants (ignoring the timezone label), while firmwares that only keep local wall-clock time are compared against local time. Verified live (-2 s on the FTYC) and against synthetic cases: a camera labelled UTC+2 against a UTC+3 host reports ~0, real drift reports the drift, and the 2024 reset case reports -71,050,819 s. Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/pppp_camera/device.py | 22 +++++++- custom_components/pppp_camera/sensor.py | 56 +++++++++---------- .../pppp_camera/translations/en.json | 6 +- 3 files changed, 47 insertions(+), 37 deletions(-) diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 48a62dc..9cd61fb 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -208,11 +208,9 @@ async def _async_fetch_extra_info(self) -> None: try: decoded = parse_datetime_block(await get_datetime(timeout=4)) if local := decoded.get("local"): - # Store the camera clock together with the monotonic - # instant it was read, so the sensor can project it - # forward instead of showing a frozen timestamp. info["camera_time"] = dt.datetime.strptime(local, "%Y-%m-%d %H:%M:%S") info["camera_time_read_at"] = time.monotonic() + info["clock_offset"] = self._clock_offset(decoded, info["camera_time"]) # This same response carries the timezone, so keeping it costs # nothing and lets the timezone entity refresh along with the # clock. Only a real zone: firmwares that manage their own @@ -240,6 +238,23 @@ async def _async_fetch_extra_info(self) -> None: self.extra_info = info + @staticmethod + def _clock_offset(decoded: dict, camera_time: dt.datetime) -> int: + """Seconds the camera clock is ahead of (+) or behind (-) Home Assistant. + + Measured against whichever notion of time the firmware actually keeps, + so a camera configured for a different timezone doesn't look broken: + + - Firmwares that store a real UTC timestamp are compared as instants, + which ignores the timezone label entirely. + - Firmwares that only keep local wall-clock time (no timezone field) + are compared against Home Assistant's local time, the only common + ground available. + """ + if decoded.get("layout") == "utc+tz" and (ts := decoded.get("timestamp")): + return round(ts - dt_util.utcnow().timestamp()) + return round((camera_time - dt_util.now().replace(tzinfo=None)).total_seconds()) + @property def _is_streaming(self) -> bool: """True while the camera is actively sending video.""" @@ -490,6 +505,7 @@ async def async_sync_datetime(self, data=None) -> None: "value just written", self.dev_id) self.extra_info["camera_time"] = now.replace(tzinfo=None) self.extra_info["camera_time_read_at"] = time.monotonic() + self.extra_info["clock_offset"] = 0 async_dispatcher_send(self.hass, self.signal_available) diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index 7122a8a..d7bab1a 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -2,10 +2,8 @@ from __future__ import annotations -import time from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta from typing import Any from homeassistant.components.sensor import ( @@ -44,28 +42,17 @@ class PPPPSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[dict[str, Any]], Any] supported_fn: Callable[[dict[str, Any]], bool] - # Re-render on HA's poll interval. Only for values derived from elapsed - # time (the camera clock); polling never touches the camera itself. - poll: bool = False # Which device poll group keeps this value fresh. None for values that # never change (timezone), so they never cause a camera round trip. poll_group: str | None = None + # Extra state attributes, for context that doesn't belong in the state. + attrs_fn: Callable[[dict[str, Any]], dict[str, Any]] | None = None -def _camera_time(props: dict[str, Any]) -> str | None: - """The camera's own clock, advanced by the time since it was read. - - The cameras never push updates and are only queried on connect, so a raw - reading would sit frozen at whatever it said during setup. Projecting it - forward keeps it comparable with local time at a glance -- a camera whose - clock or timezone is wrong stays visibly offset. - """ - read = props.get("camera_time") - read_at = props.get("camera_time_read_at") - if read is None or read_at is None: - return None - elapsed = max(0.0, time.monotonic() - read_at) - return (read + timedelta(seconds=elapsed)).strftime("%Y-%m-%d %H:%M:%S") +def _clock_attrs(props: dict[str, Any]) -> dict[str, Any]: + """Show the reading the offset was derived from.""" + camera_time = props.get("camera_time") + return {"camera_time": camera_time.strftime("%Y-%m-%d %H:%M:%S")} if camera_time else {} SENSORS: tuple[PPPPSensorEntityDescription, ...] = ( @@ -157,13 +144,20 @@ def _camera_time(props: dict[str, Any]) -> str | None: supported_fn=lambda props: bool(props.get("tz")), ), PPPPSensorEntityDescription( - key="camera_time", - translation_key="camera_time", + key="clock_offset", + translation_key="clock_offset", poll_group=POLL_GROUP_INFO, + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - poll=True, - value_fn=_camera_time, - supported_fn=lambda props: props.get("camera_time") is not None, + # How far the camera clock is ahead (+) or behind (-) Home Assistant. + # Reported instead of the camera's time itself: the offset answers the + # question the sensor exists for ("is the clock right?"), stays put + # between readings, and can't drift into a plausible-looking lie the + # way a locally-advanced clock could. + value_fn=lambda props: props.get("clock_offset"), + supported_fn=lambda props: props.get("clock_offset") is not None, + attrs_fn=_clock_attrs, ), PPPPSensorEntityDescription( key="ssid", @@ -213,8 +207,6 @@ def __init__( super().__init__(device) self.entity_description = description self._attr_unique_id = f"{self.device.dev_id}_{description.key}" - # Overrides the base class's push-only default for time-derived values. - self._attr_should_poll = description.poll async def async_added_to_hass(self) -> None: """Claim the poll group this sensor's value comes from. @@ -228,12 +220,14 @@ async def async_added_to_hass(self) -> None: self.device.register_poll_group(self.entity_description.poll_group) ) - async def async_update(self) -> None: - """Re-render only. Polling must never reach out to the camera: these - devices accept a single client, so waking one for a diagnostic value - would fight with streaming.""" - @property def native_value(self) -> Any: """Return the current value from the camera's last-known properties.""" return self.entity_description.value_fn(_properties(self.device)) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return supporting context, if this sensor provides any.""" + if self.entity_description.attrs_fn is None: + return None + return self.entity_description.attrs_fn(_properties(self.device)) diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index 36276a4..4d229c8 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -53,11 +53,11 @@ "timezone": { "name": "Timezone" }, - "camera_time": { - "name": "Camera time" - }, "ssid": { "name": "Wi-Fi network" + }, + "clock_offset": { + "name": "Clock offset" } }, "select": { From 163175248c5bfbffdd578f4ced64ecbb9432a2f8 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:31:33 +0300 Subject: [PATCH 27/42] Number this release 1.2.0 and pin aiopppp 0.3.0 Upstream's last published integration is 1.1.2 (pinning aiopppp 0.2.3), so 1.2.0 is the next minor. Device testing had bumped this twice -- to 1.2.0 and then 1.3.0 -- but neither was published, and 1.3.0 would imply a 1.2.0 release that does not exist. Collapse both bumps into one and follow the library back to 0.3.0. Also refresh two comments that still described the old ticking camera-time sensor, which the clock-offset sensor replaced. Co-Authored-By: Claude Opus 5 (1M context) --- custom_components/pppp_camera/device.py | 15 +++++++-------- custom_components/pppp_camera/manifest.json | 4 ++-- custom_components/pppp_camera/sensor.py | 2 +- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 9cd61fb..3e48e12 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -480,15 +480,15 @@ async def async_sync_datetime(self, data=None) -> None: raise HomeAssistantError("This camera does not support setting the time") # The camera stores the timezone as seconds WEST of UTC; passing # the east-positive offset here inverted every sync (UTC+3 became - # UTC-3). aiopppp>=0.4.0 computes the correct wire value itself + # UTC-3). aiopppp>=0.3.0 computes the correct wire value itself # when tz_seconds is left unset, so don't second-guess it. now = dt_util.now() await set_datetime(now) - # Re-read the clock we just set. The camera-time sensor projects - # its last reading forward, so without this it would keep counting - # up from the pre-sync value -- showing the old (wrong) time as if - # the sync had done nothing, until the next info poll an hour on. + # Re-read the clock we just set. The clock-offset sensor reports + # the difference measured at the last reading, so without this it + # would keep showing the pre-sync offset -- as if the sync had done + # nothing -- until the next info poll an hour on. # # set_datetime() already issued its own read, and these cameras # drop commands that arrive back-to-back, so let it settle first. @@ -498,9 +498,8 @@ async def async_sync_datetime(self, data=None) -> None: if self.extra_info.get("camera_time_read_at") == read_at: # The read-back didn't land. We still know what we just wrote, - # and anything is better than continuing to count up from the - # pre-sync value; the next info poll replaces this with a - # genuine reading. + # and that beats leaving the stale pre-sync offset on display; + # the next info poll replaces this with a genuine reading. LOGGER.debug("%s: clock read-back after sync failed; assuming the " "value just written", self.dev_id) self.extra_info["camera_time"] = now.replace(tzinfo=None) diff --git a/custom_components/pppp_camera/manifest.json b/custom_components/pppp_camera/manifest.json index cf99a88..64d6bdc 100644 --- a/custom_components/pppp_camera/manifest.json +++ b/custom_components/pppp_camera/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["ffmpeg"], "iot_class": "local_polling", "loggers": ["aiopppp"], - "requirements": ["aiopppp==0.4.0"], - "version": "1.3.0" + "requirements": ["aiopppp==0.3.0"], + "version": "1.2.0" } diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index d7bab1a..628940a 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -65,7 +65,7 @@ def _clock_attrs(props: dict[str, Any]) -> dict[str, Any]: state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, # JSON cameras report batValue (percent). Binary cameras report - # batLevel in MILLIVOLTS; aiopppp>=0.4.0 derives batPercent from it + # batLevel in MILLIVOLTS; aiopppp>=0.3.0 derives batPercent from it # (None when externally powered / out of battery range), so use that # -- feeding batLevel here showed readings like "4213%". value_fn=lambda props: _first(props, "batValue", "batPercent"), From 96ba5177b7983f25f91366a6ef4424c7b52ef08b Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:41:58 +0300 Subject: [PATCH 28/42] Register info_poll_interval in the YAML schema, refresh README The poll-interval options were merged into a single vol.Optional by mistake: vol.Optional(CONF_STATUS_POLL_INTERVAL, CONF_INFO_POLL_INTERVAL, default=DEFAULT_STATUS_POLL_INTERVAL) voluptuous' second positional argument is `msg`, so info_poll_interval was never a key at all -- setting it in configuration.yaml failed with "extra keys not allowed", and a bad status_poll_interval reported the string "info_poll_interval" as its error message. Split into two markers and document both in the module docstring. The README still described the upstream state: JSON-only support, FTYC video broken, audio "TBD", and none of the entities added since. Rewrite the feature list and device table, and add sections for the entities, the demand-driven polling model, and the services. The device table is flagged as library-verified, since the HA side has not been run against hardware yet. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 137 ++++++++++++++++++++-- custom_components/pppp_camera/__init__.py | 12 +- 2 files changed, 137 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ffcd0f8..598a082 100644 --- a/README.md +++ b/README.md @@ -7,25 +7,34 @@ These cameras typically use the **Peer-to-Peer protocol** for communication, and ## Features -- Supports A9, X5, and similar PPPP protocol cameras (Only JSON protocol is supported for now) +- Supports A9, X5, and similar PPPP protocol cameras, over **both the JSON and + the binary control protocol** (binary is currently the better-tested path) - Live streaming via aiopppp - Snapshot support -- PTZ control through actions/services +- PTZ control through actions/services, including preset slots - White lights and IR lights control +- **Talk-back**: play a media or TTS source through the camera speaker +- Video resolution as a config entity (dropdown) +- Diagnostic sensors: battery, power source, signal, uptime, SD card usage, + Wi-Fi network, camera clock offset and timezone +- Camera clock sync button +- On-demand connections — the camera is only held open while something needs + it, because these cameras accept **one client at a time** - Support for webrtc custom component - Automatic device discovery -- (TBD) Sound streaming +- (TBD) Listening to camera audio in Home Assistant — the library supports it, + but the HA camera entity streams video only ## Tested camera prefixes -| Prefix | Protocol | Video | [Audio*](https://github.com/devbis/aiopppp/issues/6) | PTZ | White Light | IR Light | Reboot | -|:---------|:---------|:-----:|:---------------------------------------------------------------:|:---:|:-----------:|:--------:|:------:| -| **DGOK** | 📜 JSON | ✅ | ✖️ | ✅ | ✅ | ✅ | ✅ | -| **PTZA** | 🔢 Binary| ✅ | ✖️ | ✅ | ✅ | 🚫 | ✅ | -| **FTYC** | 🔢 Binary| [❌*](https://github.com/devbis/aiopppp/issues/8)| ✖️ | 🚫 | 🚫 | ✅ | ✅ | -| [**BATE***](https://github.com/devbis/pppp_camera/issues/4) | 🔢 Binary|❔ |✖️ | ❔ | ❔ | ❔ | ❔ | -| [**DGB***](https://github.com/devbis/pppp_camera/issues/2) | 📜 JSON |⚠️ |✖️ | ❔ | ❔ | ❔ | ❔ | -| [**ACCQ***](https://github.com/devbis/pppp_camera/issues/1) | ❔ Unknown|✖️|✖️ | ✖️ | ✖️ | ✖️ | ✖️ | +| Prefix | Protocol | Video | Snapshot | PTZ | White Light | IR Light | Reboot | Resolution | Talk | Time sync | +|:---------|:---------|:-----:|:--------:|:---:|:-----------:|:--------:|:------:|:----------:|:----:|:---------:| +| **DGOK** | 📜 JSON | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✖️ | ✖️ | ✖️ | +| **PTZA** | 🔢 Binary| ✅ | ✅ | ✅ | ✅ | 🚫 | ✅ | ✅ | ✅ | ✅ | +| **FTYC** | 🔢 Binary| ✅ | ✅ | 🚫 | 🚫 | ✅ | ✅ | ✅ | 🚫 | ⚠️ | +| [**BATE***](https://github.com/devbis/pppp_camera/issues/4) | 🔢 Binary|❔ |❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | +| [**DGB***](https://github.com/devbis/pppp_camera/issues/2) | 📜 JSON |⚠️ |❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ✖️ | ✖️ | +| [**ACCQ***](https://github.com/devbis/pppp_camera/issues/1) | ❔ Unknown|✖️|✖️ | ✖️ | ✖️ | ✖️ | ✖️ | ✖️ | ✖️ | ✖️ | **Legend:** -  ✅  **Working**: Feature is fully functional. @@ -35,6 +44,70 @@ These cameras typically use the **Peer-to-Peer protocol** for communication, and -  🚫  **Not supported**: Feature is not supported by the device. -  ❔   **Not tested**: Feature has not been tested on the device. +Notes: FTYC has no speaker, so talk-back cannot be tested there. FTYC time +sync sets the clock but has no timezone field to write, so its UTC offset +stays whatever the vendor app configured. JSON cameras expose no set-time +command. PTZ presets are sent using the scheme found in the vendor app, but +none of the tested cameras act on them — see +[services](#services) below. + +> **Status:** the capability matrix above is what the underlying `aiopppp` +> library was verified to do against real cameras. The newer Home Assistant +> entities built on top of it (diagnostic sensors, resolution select, talk +> service, clock sync) are implemented but **have not yet been exercised in a +> running Home Assistant** — treat that side as untested. + +## Entities + +One device is created per camera. Which entities appear depends on what the +camera actually reports, so a mains-powered camera gets no battery sensor and a +camera without an SD card gets no usage sensor. + +| Entity | Platform | Notes | +|:-------|:---------|:------| +| Camera | `camera` | Live stream, snapshots, and turn on/off (starts and stops the video stream) | +| White Lamp / IR Lamp | `switch`, `light` or `button` | Only for cameras reporting that lamp. The platform is chosen by the `platform.lamp` option | +| Reboot | `button` | Always available | +| Sync time | `button` | Binary-protocol cameras only | +| Resolution | `select` | Binary-protocol cameras only. QVGA / VGA / HD / FD / UD | +| Battery | `sensor` | Only when the camera reports a real battery voltage | +| Power source | `sensor` | External or Battery. Only alongside a battery reading — mains-only cameras leave the field unpopulated rather than reporting "external" | +| Clock offset | `sensor` | Seconds the camera clock is ahead (+) or behind (−) Home Assistant, with the raw camera time as an attribute | +| Wi-Fi network | `sensor` | SSID the camera is joined to | +| Timezone | `sensor` | Disabled by default. Not created for firmwares that don't store one | +| Signal strength | `sensor` | Disabled by default | +| Uptime | `sensor` | Disabled by default. Hidden entirely when the firmware reports a nonsense value | +| SD card usage | `sensor` | Disabled by default. Only when a card is present | + +All sensors are diagnostic entities; the resolution select and the reboot/sync +buttons are config entities. + +### Polling + +These cameras push nothing, and only one client may be connected at a time, so +values are refreshed by briefly opening a session on a timer. Polling is +**demand-driven**: a group of values is only fetched while at least one enabled +entity actually uses it. + +| Group | Values | Default interval | +|:------|:-------|:-----------------| +| Status | Battery, power source, uptime, SD usage | 300 s | +| Info | Camera clock offset, Wi-Fi SSID | 3600 s | + +So a camera with no battery and no SD card is never status-polled, and +disabling those entities stops the polling too. Set either interval to `0` to +disable it outright. The timezone sensor never triggers a poll of its own — it +rides along on the clock response, which already carries it. + +## Services + +| Service | Description | +|:--------|:------------| +| `pppp_camera.ptz` | Pan (`LEFT`/`RIGHT`) or tilt (`UP`/`DOWN`) the camera | +| `pppp_camera.ptz_preset` | Move to (`goto`) or store (`set`) a preset slot, 0–255. Implemented from the vendor app, but **no tested camera acts on it** | +| `pppp_camera.reboot` | Reboot the camera | +| `pppp_camera.talk` | Play an audio media or TTS source through the camera speaker | + ## Installation ### Prerequisites @@ -55,6 +128,10 @@ Or manually copy pppp_camera folder to custom_components folder in your config f Add cameras through Home Assistant's **Devices & Services** interface by camera IP address. If username and passwords are blank, it will use default values for authentication: `admin:6666`. +Per-camera settings (connection and polling behaviour) are available afterwards +via **Configure** on the integration entry, and override the YAML defaults +below. + ### Advanced YAML Configuration (Optional) For advanced configuration options, you can add the following to your `configuration.yaml` file: @@ -78,6 +155,8 @@ pppp_camera: ip: 192.168.1.255 # if 'ip' is not specified, discovery will listen on all interfaces idle_disconnect_delay: 5 # seconds to keep a session warm after the last operation + status_poll_interval: 300 # seconds between battery/uptime/SD refreshes + info_poll_interval: 3600 # seconds between clock/SSID refreshes ``` ### Configuration Parameters @@ -116,6 +195,22 @@ Configure automatic device discovery on your network. the session and prevents a fire-and-forget command from being cut off by an immediate disconnect. Set to `0` to disconnect immediately after each operation. +#### `status_poll_interval` (optional) + +- **`status_poll_interval`** (integer, default: `300`): Seconds between refreshes + of battery, power source, uptime and SD card usage. Only polled while at least + one of those entities is enabled, so a camera without a battery or SD card is + never contacted for them. Set to `0` to disable. + +#### `info_poll_interval` (optional) + +- **`info_poll_interval`** (integer, default: `3600`): Seconds between refreshes + of the camera clock and Wi-Fi network. These barely change — the SSID only when + the camera is re-provisioned — so this is deliberately much slower than the + status poll. Set to `0` to disable. + +All four of the above can also be set per camera from the integration's +**Configure** dialog, which takes precedence over the YAML values. ## Usage @@ -133,6 +228,17 @@ target: entity_id: camera.dgok_123456_xxxxx ``` +Talk-back plays any media or TTS source through the camera speaker: + +```yaml +action: pppp_camera.talk +data: + media: + media_content_id: media-source://tts/tts.google_en_com?message=Someone+is+at+the+door + media_content_type: provider +target: + entity_id: camera.ptza_123456_xxxxx +``` ## WebRTC component configuration example: @@ -174,6 +280,15 @@ shortcuts: - **Camera not connecting?** Ensure IP is correct and phone application is not connected. Only one client can connect. - **No video stream?** Sometimes camera doesn't start streaming. Reboot it. +- **Resolution shows as unknown?** The camera only reports its real video + parameters while it is streaming; an idle camera answers with an empty table. + Start the stream and the value fills in a couple of seconds later. +- **Clock offset looks stale after pressing Sync time?** The value is re-read a + moment after the write. If that read-back doesn't land, the next info poll + replaces it with a genuine reading. +- **Missing sensors?** Most are conditional (see [Entities](#entities)), and + signal / uptime / SD usage / timezone are disabled by default — enable them + from the device page. ## Contributing diff --git a/custom_components/pppp_camera/__init__.py b/custom_components/pppp_camera/__init__.py index bb1acf2..44e5d35 100644 --- a/custom_components/pppp_camera/__init__.py +++ b/custom_components/pppp_camera/__init__.py @@ -70,9 +70,12 @@ ): vol.All(vol.Coerce(int), vol.Range(min=0)), vol.Optional( CONF_STATUS_POLL_INTERVAL, - CONF_INFO_POLL_INTERVAL, default=DEFAULT_STATUS_POLL_INTERVAL, ): vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Optional( + CONF_INFO_POLL_INTERVAL, + default=DEFAULT_INFO_POLL_INTERVAL, + ): vol.All(vol.Coerce(int), vol.Range(min=0)), } ) }, @@ -100,6 +103,13 @@ # if 'ip' is not specified, discovery will listen on all interfaces idle_disconnect_delay: 5 # seconds to keep a session warm after the last # operation (0 = disconnect immediately) + status_poll_interval: 300 # seconds between battery/uptime/SD refreshes + # (0 = never poll) + info_poll_interval: 3600 # seconds between clock/SSID refreshes + # (0 = never poll) + +All of these can also be set per camera in the integration's options, which +takes precedence over the values here. """ From ac45a9d709dec582c4756baf9bb90eeb74172a84 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:47:09 +0300 Subject: [PATCH 29/42] Fix talk-back on local media, and stop swallowing ffmpeg errors async_process_play_media_url was called as an attribute of media_source, where it does not exist -- it lives in media_player. Every talk action on a media-source item died with AttributeError, surfaced in the UI as the useless "Unknown error". media_player is already imported here for the ATTR_MEDIA_* constants, so this adds no new coupling. The step is required, not incidental: media_source resolves to a signed but relative URL ("/media/local/x.mp3?authSig=..."), and ffmpeg needs an absolute one. Also stop sending ffmpeg's stderr to DEVNULL. A URL it could not fetch produced no PCM, no exception and no log line -- the action reported success and the camera stayed silent, which is the worst possible outcome while testing. stderr is now drained concurrently (it would otherwise block once the pipe buffer fills) and the tail is reported in a HomeAssistantError when no audio was produced. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 11 +++++++++ custom_components/pppp_camera/camera.py | 8 ++++++- custom_components/pppp_camera/device.py | 30 ++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 598a082..1a0de8e 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,13 @@ target: entity_id: camera.ptza_123456_xxxxx ``` +The easiest way to try it is **Developer tools → Actions → Talk**, picking a +short file with the media browser (anything ffmpeg can decode works; it is +transcoded to the 8 kHz mono the camera expects). Note that the action runs for +as long as the audio lasts — it is streamed to the camera in real time — so +test with a few seconds of audio rather than a full song. The camera must +actually have a speaker; not all models do. + ## WebRTC component configuration example: Component project page: https://github.com/AlexxIT/WebRTC @@ -289,6 +296,10 @@ shortcuts: - **Missing sensors?** Most are conditional (see [Entities](#entities)), and signal / uptime / SD usage / timezone are disabled by default — enable them from the device page. +- **Talk-back does nothing?** The action now fails loudly with ffmpeg's own + error when the media can't be decoded. If it reports that the URL could not + be fetched, check that Home Assistant's internal URL is reachable from + itself, since the audio is pulled back over HTTP before being transcoded. ## Contributing diff --git a/custom_components/pppp_camera/camera.py b/custom_components/pppp_camera/camera.py index e8a0bcc..b20495a 100644 --- a/custom_components/pppp_camera/camera.py +++ b/custom_components/pppp_camera/camera.py @@ -16,6 +16,7 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -312,5 +313,10 @@ async def async_perform_talk(self, media: dict) -> None: self.hass, media_id, self.entity_id ) media_id = resolved.url - media_id = media_source.async_process_play_media_url(self.hass, media_id) + # media_source hands back a signed but *relative* URL + # ("/media/local/x.mp3?authSig=..."); ffmpeg needs an absolute one. + # This helper lives in media_player, not media_source -- calling it as + # media_source.async_process_play_media_url raised AttributeError for + # every local media file. + media_id = async_process_play_media_url(self.hass, media_id) await self.device.async_talk(media_id) diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 3e48e12..08dd8fe 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -6,6 +6,7 @@ import contextlib import datetime as dt import time +from collections import deque from collections.abc import Callable import aiopppp @@ -447,13 +448,26 @@ async def async_talk(self, url: str) -> None: proc = await asyncio.create_subprocess_exec( ffmpeg.binary, "-nostdin", "-i", url, "-f", "s16le", "-acodec", "pcm_s16le", "-ar", "8000", "-ac", "1", "pipe:1", - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) + # Drain stderr concurrently, keeping only the tail. ffmpeg blocks + # once the pipe buffer fills, and when it can't fetch the URL its + # diagnostics are the only clue -- discarding them turned every + # failure into silence with no error at all. + stderr_tail: deque[str] = deque(maxlen=15) + + async def _drain_stderr() -> None: + async for line in proc.stderr: + stderr_tail.append(line.decode("utf-8", "replace").strip()) + + drain = self.hass.async_create_task(_drain_stderr()) + # 960 samples * 2 bytes = 120 ms per chunk at 8 kHz, matching the # camera's own audio chunking. chunk_bytes = 1920 chunk_seconds = 0.12 + sent = 0 await start_talk() try: while True: @@ -461,6 +475,7 @@ async def async_talk(self, url: str) -> None: if not pcm: break await send_audio(pcm) + sent += len(pcm) # Pace by how much audio this chunk actually represents. await asyncio.sleep(chunk_seconds * len(pcm) / chunk_bytes) finally: @@ -470,6 +485,19 @@ async def async_talk(self, url: str) -> None: proc.terminate() with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for(proc.wait(), timeout=5) + drain.cancel() + with contextlib.suppress(asyncio.CancelledError): + await drain + + if not sent: + # ffmpeg produced no audio: bad URL, unreachable HA base URL, + # unsupported container. Surface its own words rather than + # letting the service silently succeed. + detail = "; ".join(stderr_tail) or "no output from ffmpeg" + raise HomeAssistantError(f"Could not decode audio from {url}: {detail}") + LOGGER.debug( + "%s: talk-back sent %.1f s of audio", self.dev_id, sent / 16000 + ) async def async_sync_datetime(self, data=None) -> None: """Set the camera clock to Home Assistant's local time.""" From 55fe8b2a2e39bb8aad441f801b0b7c990c1e41e7 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:00:29 +0300 Subject: [PATCH 30/42] README: record talk-back as verified in a live Home Assistant Confirmed working on a PTZA camera from a local media-source file. The rest of the HA-side entities remain unexercised, so narrow the blanket "untested" caveat rather than dropping it. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1a0de8e..8980cdf 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,10 @@ none of the tested cameras act on them — see [services](#services) below. > **Status:** the capability matrix above is what the underlying `aiopppp` -> library was verified to do against real cameras. The newer Home Assistant -> entities built on top of it (diagnostic sensors, resolution select, talk -> service, clock sync) are implemented but **have not yet been exercised in a -> running Home Assistant** — treat that side as untested. +> library was verified to do against real cameras. Of the newer Home Assistant +> entities built on top of it, only **talk-back has been confirmed in a running +> Home Assistant** so far. The diagnostic sensors, resolution select and clock +> sync are implemented but not yet exercised there — treat those as untested. ## Entities From 3ad466d0cd55353bdee5551ab21979f594fa3a19 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:22:04 +0300 Subject: [PATCH 31/42] README: record HA test results, known issue, and two missing features Testing in a live Home Assistant covered everything except the SD card usage sensor (no card available), so replace the "only talk-back is confirmed" caveat with what was actually verified. Add a Known issues section for the resolution being overwritten with HD at stream start, with the workaround (set it while streaming) and a pointer from Troubleshooting. Two library capabilities the integration inherits were missing from the feature list: automatic reconnection with backoff, and discovery probing with the extended search packet as well as the plain one. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8980cdf..89dbce0 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,17 @@ These cameras typically use the **Peer-to-Peer protocol** for communication, and - PTZ control through actions/services, including preset slots - White lights and IR lights control - **Talk-back**: play a media or TTS source through the camera speaker -- Video resolution as a config entity (dropdown) +- Video resolution as a config entity (dropdown) — see + [Known issues](#known-issues) - Diagnostic sensors: battery, power source, signal, uptime, SD card usage, Wi-Fi network, camera clock offset and timezone - Camera clock sync button - On-demand connections — the camera is only held open while something needs it, because these cameras accept **one client at a time** +- Automatic reconnection with backoff when a camera drops off the network - Support for webrtc custom component -- Automatic device discovery +- Automatic device discovery, probing with both the plain and the extended + PPPP search packet — some firmwares only answer the extended one - (TBD) Listening to camera audio in Home Assistant — the library supports it, but the HA camera entity streams video only @@ -52,10 +55,25 @@ none of the tested cameras act on them — see [services](#services) below. > **Status:** the capability matrix above is what the underlying `aiopppp` -> library was verified to do against real cameras. Of the newer Home Assistant -> entities built on top of it, only **talk-back has been confirmed in a running -> Home Assistant** so far. The diagnostic sensors, resolution select and clock -> sync are implemented but not yet exercised there — treat those as untested. +> library was verified to do against real cameras. The Home Assistant side — +> camera, lamps, buttons, diagnostic sensors, resolution select, clock sync, +> talk-back, the services, discovery and the config/options flows — has now +> been exercised in a running Home Assistant against PTZA and FTYC cameras. +> The only entity still unverified is **SD card usage**, for want of a card. + +## Known issues + +- **A resolution chosen while the camera is idle is overwritten with HD when + the stream starts.** The library sets HD at stream start and deliberately + re-asserts it a few seconds in (the cameras otherwise self-downgrade and + ignore the value set at start time), so an idle selection never survives. + Set the resolution *while the stream is running* and it sticks. Note the + same re-request path runs after a video stall, so a mid-stream stall can + also revert the resolution to HD. + + Fixing it means teaching the library to prefer a chosen resolution instead + of the hardcoded default, and having the integration re-apply that choice + on connect so it survives the idle session teardown. Not implemented yet. ## Entities @@ -290,6 +308,8 @@ shortcuts: - **Resolution shows as unknown?** The camera only reports its real video parameters while it is streaming; an idle camera answers with an empty table. Start the stream and the value fills in a couple of seconds later. +- **Resolution keeps reverting to HD?** Known limitation — set it while the + stream is running. See [Known issues](#known-issues). - **Clock offset looks stale after pressing Sync time?** The value is re-read a moment after the write. If that read-back doesn't land, the next info poll replaces it with a genuine reading. From 1d09187e6c5071bb69827b8299864522b2cfb396 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:34:37 +0300 Subject: [PATCH 32/42] Drop the uptime sensor and poll the signal sensor properly aiopppp no longer publishes 'uptime': the status field the vendor SDK names sysUptime turned out to be the Wi-Fi RSSI, not a duration, so the library reports it as 'dbm' instead. The uptime sensor could therefore never become supported again -- remove it and its translation. The signal sensor was in no poll group, a leftover from when that field was believed unusable. Now that it carries a real reading from the status block, put it in the status group so it actually refreshes. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 13 ++++++------ custom_components/pppp_camera/__init__.py | 2 +- custom_components/pppp_camera/const.py | 2 +- custom_components/pppp_camera/device.py | 2 +- custom_components/pppp_camera/sensor.py | 20 +++++-------------- .../pppp_camera/translations/en.json | 5 +---- 6 files changed, 15 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 89dbce0..196d9ca 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ These cameras typically use the **Peer-to-Peer protocol** for communication, and - **Talk-back**: play a media or TTS source through the camera speaker - Video resolution as a config entity (dropdown) — see [Known issues](#known-issues) -- Diagnostic sensors: battery, power source, signal, uptime, SD card usage, +- Diagnostic sensors: battery, power source, Wi-Fi signal, SD card usage, Wi-Fi network, camera clock offset and timezone - Camera clock sync button - On-demand connections — the camera is only held open while something needs @@ -93,8 +93,7 @@ camera without an SD card gets no usage sensor. | Clock offset | `sensor` | Seconds the camera clock is ahead (+) or behind (−) Home Assistant, with the raw camera time as an attribute | | Wi-Fi network | `sensor` | SSID the camera is joined to | | Timezone | `sensor` | Disabled by default. Not created for firmwares that don't store one | -| Signal strength | `sensor` | Disabled by default | -| Uptime | `sensor` | Disabled by default. Hidden entirely when the firmware reports a nonsense value | +| Signal strength | `sensor` | Wi-Fi RSSI in dBm. Disabled by default. Not created when the firmware reports no usable value | | SD card usage | `sensor` | Disabled by default. Only when a card is present | All sensors are diagnostic entities; the resolution select and the reboot/sync @@ -109,7 +108,7 @@ entity actually uses it. | Group | Values | Default interval | |:------|:-------|:-----------------| -| Status | Battery, power source, uptime, SD usage | 300 s | +| Status | Battery, power source, signal strength, SD usage | 300 s | | Info | Camera clock offset, Wi-Fi SSID | 3600 s | So a camera with no battery and no SD card is never status-polled, and @@ -173,7 +172,7 @@ pppp_camera: ip: 192.168.1.255 # if 'ip' is not specified, discovery will listen on all interfaces idle_disconnect_delay: 5 # seconds to keep a session warm after the last operation - status_poll_interval: 300 # seconds between battery/uptime/SD refreshes + status_poll_interval: 300 # seconds between battery/signal/SD refreshes info_poll_interval: 3600 # seconds between clock/SSID refreshes ``` @@ -216,7 +215,7 @@ Configure automatic device discovery on your network. #### `status_poll_interval` (optional) - **`status_poll_interval`** (integer, default: `300`): Seconds between refreshes - of battery, power source, uptime and SD card usage. Only polled while at least + of battery, power source, signal strength and SD card usage. Only polled while at least one of those entities is enabled, so a camera without a battery or SD card is never contacted for them. Set to `0` to disable. @@ -314,7 +313,7 @@ shortcuts: moment after the write. If that read-back doesn't land, the next info poll replaces it with a genuine reading. - **Missing sensors?** Most are conditional (see [Entities](#entities)), and - signal / uptime / SD usage / timezone are disabled by default — enable them + signal / SD usage / timezone are disabled by default — enable them from the device page. - **Talk-back does nothing?** The action now fails loudly with ffmpeg's own error when the media can't be decoded. If it reports that the URL could not diff --git a/custom_components/pppp_camera/__init__.py b/custom_components/pppp_camera/__init__.py index 44e5d35..724a22f 100644 --- a/custom_components/pppp_camera/__init__.py +++ b/custom_components/pppp_camera/__init__.py @@ -103,7 +103,7 @@ # if 'ip' is not specified, discovery will listen on all interfaces idle_disconnect_delay: 5 # seconds to keep a session warm after the last # operation (0 = disconnect immediately) - status_poll_interval: 300 # seconds between battery/uptime/SD refreshes + status_poll_interval: 300 # seconds between battery/signal/SD refreshes # (0 = never poll) info_poll_interval: 3600 # seconds between clock/SSID refreshes # (0 = never poll) diff --git a/custom_components/pppp_camera/const.py b/custom_components/pppp_camera/const.py index 552b43f..69d605f 100644 --- a/custom_components/pppp_camera/const.py +++ b/custom_components/pppp_camera/const.py @@ -63,7 +63,7 @@ # command has been delivered. 0 disconnects immediately. DEFAULT_IDLE_DISCONNECT_DELAY = 5 -# How often to re-read the camera status block (battery, uptime, SD usage). +# How often to re-read the camera status block (battery, signal, SD usage). # Nothing is pushed by these cameras, so without this the values stay frozen # at whatever they were when the session first connected. # diff --git a/custom_components/pppp_camera/device.py b/custom_components/pppp_camera/device.py index 08dd8fe..ebbe98a 100644 --- a/custom_components/pppp_camera/device.py +++ b/custom_components/pppp_camera/device.py @@ -295,7 +295,7 @@ async def async_refresh_extra_info(self) -> None: async_dispatcher_send(self.hass, self.signal_available) async def async_refresh_status(self) -> None: - """Re-read the status block (battery, power source, uptime, SD usage). + """Re-read the status block (battery, power source, signal, SD usage). The cameras never push updates and the library only reads the status once, during session setup, so these values would otherwise stay frozen diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index 628940a..d98aea5 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -1,4 +1,4 @@ -"""PPPP diagnostic sensors (battery, signal, uptime).""" +"""PPPP diagnostic sensors (battery, signal, power source, SD usage).""" from __future__ import annotations @@ -74,6 +74,10 @@ def _clock_attrs(props: dict[str, Any]) -> dict[str, Any]: PPPPSensorEntityDescription( key="signal", translation_key="signal", + # dbm comes out of the binary status block, so it needs the status poll + # to refresh (it used to be in no poll group at all, back when it was + # believed to be an unusable field). + poll_group=POLL_GROUP_STATUS, device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, @@ -82,20 +86,6 @@ def _clock_attrs(props: dict[str, Any]) -> dict[str, Any]: value_fn=lambda props: _first(props, "signal", "dbm"), supported_fn=lambda props: _first(props, "signal", "dbm") is not None, ), - PPPPSensorEntityDescription( - key="uptime", - translation_key="uptime", - poll_group=POLL_GROUP_STATUS, - native_unit_of_measurement=UnitOfTime.SECONDS, - state_class=SensorStateClass.TOTAL_INCREASING, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - value_fn=lambda props: props.get("uptime"), - # Some firmwares report a garbage (negative) uptime; only expose it - # when it is a sane non-negative value. - supported_fn=lambda props: isinstance(props.get("uptime"), int) - and props["uptime"] >= 0, - ), PPPPSensorEntityDescription( key="power_source", translation_key="power_source", diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index 4d229c8..5f000ee 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -37,9 +37,6 @@ "signal": { "name": "Signal strength" }, - "uptime": { - "name": "Uptime" - }, "power_source": { "name": "Power source", "state": { @@ -110,7 +107,7 @@ }, "data_description": { "idle_disconnect_delay": "How long to keep the camera session warm after the last operation. 0 disconnects immediately.", - "status_poll_interval": "How often to refresh battery, uptime and SD usage. Only polled while at least one of those entities is enabled, so a camera without a battery or SD card is never contacted. 0 disables polling.", + "status_poll_interval": "How often to refresh battery, signal strength and SD usage. Only polled while at least one of those entities is enabled, so a camera without a battery or SD card is never contacted. 0 disables polling.", "info_poll_interval": "How often to refresh the camera clock and Wi-Fi network. These rarely change, so this can be long. 0 disables polling." } } From 169b2e2b7a3e313e4c88e0a6115842c6e68a9d8a Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:25:30 +0300 Subject: [PATCH 33/42] Restore the auth gate on the reboot button Commit ab00654 removed it, calling "auth" an unrelated login flag and asserting that reboot works without a login. Neither was true and there was no capture behind it: "auth" records whether the USER_CHK handshake succeeded, and reboot is one of the few commands a camera refuses without one -- PTZA fw 2.2.15.93 answers -1015 USER_NO_PRIVILEGE. Only this button needs the gate. Lights, PTZ, resolution and time sync all work on an unauthenticated session, so they stay gated on property presence as before. Co-Authored-By: Claude Opus 5 (1M context) --- custom_components/pppp_camera/button.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/pppp_camera/button.py b/custom_components/pppp_camera/button.py index da22d20..78c1013 100644 --- a/custom_components/pppp_camera/button.py +++ b/custom_components/pppp_camera/button.py @@ -33,10 +33,10 @@ class PPPPButtonEntityDescription(ButtonEntityDescription): translation_key="reboot", press_fn=lambda device: device.async_reboot, press_data=None, - # Reboot is always available on a set-up camera; it was previously gated - # on the unrelated "auth" login flag, which hid it whenever login failed - # even though reboot works without auth. - supported_fn=lambda device, _: True, + # Reboot is one of the few commands that needs a login: without one the + # camera answers -1015 USER_NO_PRIVILEGE. Lights, PTZ, resolution and + # time sync do not, which is why only this button is gated. + supported_fn=lambda device, _: bool(device.device.properties.get("auth", False)), device_class = ButtonDeviceClass.RESTART, entity_category = EntityCategory.CONFIG, ), From 81d97b7ad3c420cee5a246f5883e69822457bb03 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:47:29 +0300 Subject: [PATCH 34/42] Track real lamp state, and surface device/chip type Lamp entities seeded from `lamp` and `icut`, which are not trustworthy on every firmware: `lamp` is derived from the status block's function bitmap and reads 0 whenever the camera leaves that word unpopulated, and `icut` sits at 1 on PTZA no matter what the IR is doing -- so the IR entity could start out claiming "on" with nothing behind it. Add LAMP_REPORTED_PROPERTY (funcFillLight / funcIrLed), which aiopppp reports as None exactly when the camera didn't populate the word. Where a camera does report them (confirmed on FTYC) the lamp now seeds true state, claims the status poll group and drops assumed_state -- so a change made from the vendor app shows up in Home Assistant, and the UI is a real toggle instead of the two-button assumed-state control. Where it doesn't, the previous assume-our-own-writes behaviour is untouched, and no poll is claimed for a value that would be ignored. State is cached rather than read live so a just-sent command shows immediately and is corrected on the next poll, instead of flickering back to a stale reading in between. The seeding, the poll claim and the turn_on/turn_off path were duplicated between the switch and light platforms, so they move to a shared PPPPLampEntity; both platforms are now just their entity description plus the light's colour mode. PPPPBaseEntity grows a _handle_device_update hook for this: the signal it listens to fires after every poll, not only on an availability change, so subclasses that cache state need to adopt the new reading before writing. Device type: the device registry has no free-form attributes, so the rendered string goes in `model` -- which until now duplicated the DID already shown as serial_number -- and the raw halves go on an opt-in diagnostic sensor. Both render as e.g. "XR_PTZ (chip 2)", falling back to the raw number for either half, since the enums are transcribed from the vendor apps and are known to be incomplete. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 +- custom_components/pppp_camera/const.py | 11 +- custom_components/pppp_camera/entity.py | 121 +++++++++++++++++- custom_components/pppp_camera/light.py | 45 ++----- custom_components/pppp_camera/sensor.py | 27 +++- custom_components/pppp_camera/switch.py | 45 ++----- .../pppp_camera/translations/en.json | 3 + 7 files changed, 175 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 196d9ca..678afde 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,8 @@ camera without an SD card gets no usage sensor. | Entity | Platform | Notes | |:-------|:---------|:------| | Camera | `camera` | Live stream, snapshots, and turn on/off (starts and stops the video stream) | -| White Lamp / IR Lamp | `switch`, `light` or `button` | Only for cameras reporting that lamp. The platform is chosen by the `platform.lamp` option | -| Reboot | `button` | Always available | +| White Lamp / IR Lamp | `switch`, `light` or `button` | Only for cameras reporting that lamp. The platform is chosen by the `platform.lamp` option. Cameras that report real lamp state (function bitmap in the status block) track it live, so changes made from the vendor app show up; the rest assume their own writes | +| Reboot | `button` | Only when logged in — the camera refuses it otherwise | | Sync time | `button` | Binary-protocol cameras only | | Resolution | `select` | Binary-protocol cameras only. QVGA / VGA / HD / FD / UD | | Battery | `sensor` | Only when the camera reports a real battery voltage | @@ -95,6 +95,7 @@ camera without an SD card gets no usage sensor. | Timezone | `sensor` | Disabled by default. Not created for firmwares that don't store one | | Signal strength | `sensor` | Wi-Fi RSSI in dBm. Disabled by default. Not created when the firmware reports no usable value | | SD card usage | `sensor` | Disabled by default. Only when a card is present | +| Device type | `sensor` | Disabled by default. Model and chip, e.g. `XR_PTZ (chip 2)` — the same string as the device's Model, with `devType`/`devTypeName`/`chipType`/`chipTypeName` as attributes | All sensors are diagnostic entities; the resolution select and the reboot/sync buttons are config entities. diff --git a/custom_components/pppp_camera/const.py b/custom_components/pppp_camera/const.py index 69d605f..63e1169 100644 --- a/custom_components/pppp_camera/const.py +++ b/custom_components/pppp_camera/const.py @@ -54,9 +54,18 @@ POLL_GROUP_STATUS = "status" POLL_GROUP_INFO = "info" -# Maps a lamp entity key to the camera property that reports its on/off state. +# Maps a lamp entity key to the camera property whose presence proves the +# camera has that lamp at all. Both keys are always present on binary cameras, +# so this decides which entities exist -- not what state they are in. LAMP_STATE_PROPERTY = {"white_lamp": "lamp", "ir_lamp": "icut"} +# Maps a lamp entity key to the property carrying its *real* state, from the +# status block's function bitmap. None on firmwares that don't populate it +# (PTZA), which is exactly what LAMP_STATE_PROPERTY can't tell you: `lamp` is +# derived from an unpopulated word and reads 0 there, and `icut` sits at 1 +# whatever the IR is doing. So a lamp reads live only where this is not None. +LAMP_REPORTED_PROPERTY = {"white_lamp": "funcFillLight", "ir_lamp": "funcIrLed"} + # Seconds to keep a camera session open after the last in-flight operation # finishes. Keeping it warm lets back-to-back commands (e.g. PTZ bursts) reuse # the session and avoids tearing the connection down before a fire-and-forget diff --git a/custom_components/pppp_camera/entity.py b/custom_components/pppp_camera/entity.py index 4c33aa1..0ab1216 100644 --- a/custom_components/pppp_camera/entity.py +++ b/custom_components/pppp_camera/entity.py @@ -1,13 +1,42 @@ from __future__ import annotations +from typing import Any + +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DOMAIN +from .const import ( + DOMAIN, + LAMP_REPORTED_PROPERTY, + LAMP_STATE_PROPERTY, + POLL_GROUP_STATUS, +) from .device import PPPPDevice +def format_device_type(properties: dict[str, Any]) -> str | None: + """Render the camera's model as "XR_PTZ (TX)". + + Falls back to the raw number for either half when aiopppp has no name for + it -- "XR_PTZ (chip 2)" -- because the enums are transcribed from the vendor + apps and are known to be incomplete. Returns None when the camera reports + neither, which is the case for JSON cameras. + """ + dev_type = properties.get("devType") + chip_type = properties.get("chipType") + dev = properties.get("devTypeName") or ( + f"type {dev_type}" if dev_type is not None else None + ) + chip = properties.get("chipTypeName") or ( + f"chip {chip_type}" if chip_type is not None else None + ) + if dev and chip: + return f"{dev} ({chip})" + return dev or chip + + class PPPPBaseEntity(Entity): """Base class common to all PPPP entities.""" @@ -20,13 +49,23 @@ def __init__(self, device: PPPPDevice) -> None: self.device: PPPPDevice = device async def async_added_to_hass(self) -> None: - """Refresh state when the device's availability changes.""" + """Refresh state when the device's data or availability changes.""" self.async_on_remove( async_dispatcher_connect( - self.hass, self.device.signal_available, self.async_write_ha_state + self.hass, self.device.signal_available, self._handle_device_update ) ) + @callback + def _handle_device_update(self) -> None: + """Handle a refresh of the device's properties or availability. + + Subclasses that cache state override this to adopt the new reading + before writing; the signal also fires after each poll, not only on an + availability change. + """ + self.async_write_ha_state() + @property def available(self): """Return True if device is available.""" @@ -39,7 +78,9 @@ def device_info(self) -> DeviceInfo: camera_properties = self.device.device.properties return DeviceInfo( identifiers={(DOMAIN, self.device.dev_id)}, - model=self.device.dev_id, + # Device type and chip, e.g. "XR_PTZ (chip 2)". Falls back to the + # device id for cameras that report neither. + model=format_device_type(camera_properties) or self.device.dev_id, model_id=camera_properties.get('sensor'), serial_number=self.device.dev_id, hw_version=camera_properties.get('mcuver'), @@ -49,3 +90,75 @@ def device_info(self) -> DeviceInfo: # accurate, but it makes the address visible as plain text. sw_version=self.device.host, ) + + +class PPPPLampEntity(PPPPBaseEntity): + """Shared on/off behaviour for the white and IR lamps. + + Cameras whose status block populates the function bitmap report real lamp + state (confirmed on FTYC): those entities follow the status poll, so a + change made from the vendor app shows up here. The rest can only be + assumed, and keep the previous behaviour of remembering what we last sent. + """ + + _attr_has_entity_name = True + + def __init__(self, device: PPPPDevice, description) -> None: + """Initialize the lamp.""" + super().__init__(device) + + self.entity_description = description + self._attr_unique_id = f"{device.dev_id}_{description.key}" + self._reported_property = LAMP_REPORTED_PROPERTY.get(description.key) + reported = ( + device.device.properties.get(self._reported_property) + if self._reported_property + else None + ) + self._reports_state = reported is not None + self._attr_assumed_state = not self._reports_state + + if self._reports_state: + self._attr_is_on = bool(reported) + else: + prop = LAMP_STATE_PROPERTY.get(description.key) + self._attr_is_on = bool(device.device.properties.get(prop)) if prop else False + + async def async_added_to_hass(self) -> None: + """Claim the status poll, but only where it carries a real reading.""" + await super().async_added_to_hass() + if self._reports_state: + self.async_on_remove(self.device.register_poll_group(POLL_GROUP_STATUS)) + + @callback + def _handle_device_update(self) -> None: + """Adopt the camera's own reading after a refresh. + + State is cached in _attr_is_on rather than read live so that a just-sent + command shows immediately and is corrected here on the next poll, + instead of flickering back to a stale reading in between. + """ + if self._reports_state: + reported = self.device.device.properties.get(self._reported_property) + if reported is not None: + self._attr_is_on = bool(reported) + super()._handle_device_update() + + async def _async_set_lamp(self, is_on: bool) -> None: + description = self.entity_description + if is_on: + await description.turn_on_fn(self.device)(description.turn_on_data) + else: + await description.turn_off_fn(self.device)(description.turn_off_data) + # Commit state only after the command succeeds, so a failed command + # doesn't leave the UI showing the wrong state. + self._attr_is_on = is_on + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the lamp on.""" + await self._async_set_lamp(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the lamp off.""" + await self._async_set_lamp(False) diff --git a/custom_components/pppp_camera/light.py b/custom_components/pppp_camera/light.py index eb44d98..f90d736 100644 --- a/custom_components/pppp_camera/light.py +++ b/custom_components/pppp_camera/light.py @@ -14,7 +14,7 @@ from .const import DOMAIN, CONF_LAMP, LAMP_STATE_PROPERTY from .device import PPPPDevice -from .entity import PPPPBaseEntity +from .entity import PPPPLampEntity from .config_helpers import get_platform_config @@ -72,44 +72,15 @@ async def async_setup_entry( ) -class PPPPLight(PPPPBaseEntity, LightEntity): - """A PPPP light.""" +class PPPPLight(PPPPLampEntity, LightEntity): + """A PPPP lamp exposed as a light. + + State handling (seeding, live tracking where the camera reports it, and the + turn_on/turn_off write path) lives in PPPPLampEntity, shared with the switch + platform. + """ entity_description: PPPPLightEntityDescription - _attr_has_entity_name = True # Set supported color modes for on/off lights _attr_supported_color_modes = {ColorMode.ONOFF} _attr_color_mode = ColorMode.ONOFF - # These cameras can't reliably report lamp state, so it is assumed. - _attr_assumed_state = True - - def __init__( - self, device: PPPPDevice, description: PPPPLightEntityDescription - ) -> None: - """Initialize the light.""" - super().__init__(device) - - self._attr_unique_id = f"{self.device.dev_id}_{description.key}" - #self._attr_name = description.translation_key - self.entity_description = description - # Seed from the camera's reported state instead of always starting off. - prop = LAMP_STATE_PROPERTY.get(description.key) - self._attr_is_on = bool(device.device.properties.get(prop)) if prop else False - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on light.""" - await self.entity_description.turn_on_fn(self.device)( - self.entity_description.turn_on_data - ) - # Commit state only after the command succeeds, so a failed command - # doesn't leave the UI showing the wrong state. - self._attr_is_on = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off light.""" - await self.entity_description.turn_off_fn(self.device)( - self.entity_description.turn_off_data - ) - self._attr_is_on = False - self.async_write_ha_state() diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index d98aea5..aed0641 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -24,7 +24,7 @@ from .const import DOMAIN, POLL_GROUP_INFO, POLL_GROUP_STATUS from .device import PPPPDevice -from .entity import PPPPBaseEntity +from .entity import PPPPBaseEntity, format_device_type def _first(props: dict[str, Any], *keys: str) -> Any: @@ -55,6 +55,19 @@ def _clock_attrs(props: dict[str, Any]) -> dict[str, Any]: return {"camera_time": camera_time.strftime("%Y-%m-%d %H:%M:%S")} if camera_time else {} +def _device_type_attrs(props: dict[str, Any]) -> dict[str, Any]: + """The raw halves behind the rendered model string. + + Reported even when None: aiopppp's enums come from the vendor apps and are + incomplete, so "chipType 2, chipTypeName None" is a useful thing to see + rather than an absent attribute. + """ + return { + key: props.get(key) + for key in ("devType", "devTypeName", "chipType", "chipTypeName") + } + + SENSORS: tuple[PPPPSensorEntityDescription, ...] = ( PPPPSensorEntityDescription( key="battery", @@ -157,6 +170,18 @@ def _clock_attrs(props: dict[str, Any]) -> dict[str, Any]: value_fn=lambda props: props.get("ssid"), supported_fn=lambda props: bool(props.get("ssid")), ), + PPPPSensorEntityDescription( + key="device_type", + translation_key="device_type", + entity_category=EntityCategory.DIAGNOSTIC, + # Off by default: the same string is already the device's Model, so + # this exists for the raw numbers behind it, in the attributes. + entity_registry_enabled_default=False, + # No poll_group -- a camera's model doesn't change. + value_fn=format_device_type, + supported_fn=lambda props: format_device_type(props) is not None, + attrs_fn=_device_type_attrs, + ), ) diff --git a/custom_components/pppp_camera/switch.py b/custom_components/pppp_camera/switch.py index 68a0ab4..32c4e41 100644 --- a/custom_components/pppp_camera/switch.py +++ b/custom_components/pppp_camera/switch.py @@ -14,7 +14,7 @@ from .const import DOMAIN, CONF_LAMP, LAMP_STATE_PROPERTY from .device import PPPPDevice -from .entity import PPPPBaseEntity +from .entity import PPPPLampEntity from .config_helpers import get_platform_config @@ -72,41 +72,12 @@ async def async_setup_entry( ) -class PPPPSwitch(PPPPBaseEntity, SwitchEntity): - """A PPPP switch.""" +class PPPPSwitch(PPPPLampEntity, SwitchEntity): + """A PPPP lamp exposed as a switch. + + State handling (seeding, live tracking where the camera reports it, and the + turn_on/turn_off write path) lives in PPPPLampEntity, shared with the light + platform. + """ entity_description: PPPPSwitchEntityDescription - _attr_has_entity_name = True - # These cameras can't reliably report lamp state, so it is assumed. - _attr_assumed_state = True - - def __init__( - self, device: PPPPDevice, description: PPPPSwitchEntityDescription - ) -> None: - """Initialize the switch.""" - super().__init__(device) - - self._attr_unique_id = f"{self.device.dev_id}_{description.key}" - #self._attr_name = description.translation_key - self.entity_description = description - # Seed from the camera's reported state instead of always starting off. - prop = LAMP_STATE_PROPERTY.get(description.key) - self._attr_is_on = bool(device.device.properties.get(prop)) if prop else False - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on switch.""" - await self.entity_description.turn_on_fn(self.device)( - self.entity_description.turn_on_data - ) - # Commit state only after the command succeeds, so a failed command - # doesn't leave the UI showing the wrong state. - self._attr_is_on = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off switch.""" - await self.entity_description.turn_off_fn(self.device)( - self.entity_description.turn_off_data - ) - self._attr_is_on = False - self.async_write_ha_state() diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index 5f000ee..2cd552b 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -55,6 +55,9 @@ }, "clock_offset": { "name": "Clock offset" + }, + "device_type": { + "name": "Device type" } }, "select": { From 837fb2972a2d16414757f80f4cd6c1b7a5d10fa4 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:59:28 +0300 Subject: [PATCH 35/42] Move the device type to Manufacturer, and show names only model goes back to the device id. The type moves to manufacturer, which is otherwise unset -- no real manufacturer is discoverable over PPPP -- and is rendered as "DevType/ChipType", e.g. "BK_A9/TX_817_810". Names only: a half aiopppp can't name is dropped rather than shown as a bare number, so PTZA reads "XR_PTZ" instead of "XR_PTZ (chip 2)". The numbers are not lost -- they are what the device_type sensor's attributes are for. That sensor is now keyed on the raw values rather than the rendered name, so a camera whose type has no name at all still gets one: its state is unknown while the attributes carry devType and chipType, which is exactly the camera whose numbers are worth seeing. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- custom_components/pppp_camera/entity.py | 32 ++++++++++--------------- custom_components/pppp_camera/sensor.py | 11 ++++++--- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 678afde..cdfe71b 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ camera without an SD card gets no usage sensor. | Timezone | `sensor` | Disabled by default. Not created for firmwares that don't store one | | Signal strength | `sensor` | Wi-Fi RSSI in dBm. Disabled by default. Not created when the firmware reports no usable value | | SD card usage | `sensor` | Disabled by default. Only when a card is present | -| Device type | `sensor` | Disabled by default. Model and chip, e.g. `XR_PTZ (chip 2)` — the same string as the device's Model, with `devType`/`devTypeName`/`chipType`/`chipTypeName` as attributes | +| Device type | `sensor` | Disabled by default. Device and chip type, e.g. `XR_PTZ/TX_817_810` — the same string shown as the device's Manufacturer, with `devType`/`devTypeName`/`chipType`/`chipTypeName` as attributes | All sensors are diagnostic entities; the resolution select and the reboot/sync buttons are config entities. diff --git a/custom_components/pppp_camera/entity.py b/custom_components/pppp_camera/entity.py index 0ab1216..0d9a1d7 100644 --- a/custom_components/pppp_camera/entity.py +++ b/custom_components/pppp_camera/entity.py @@ -17,24 +17,16 @@ def format_device_type(properties: dict[str, Any]) -> str | None: - """Render the camera's model as "XR_PTZ (TX)". + """Render the camera's type as "DevType/ChipType", e.g. "XR_PTZ/TX_817_810". - Falls back to the raw number for either half when aiopppp has no name for - it -- "XR_PTZ (chip 2)" -- because the enums are transcribed from the vendor - apps and are known to be incomplete. Returns None when the camera reports - neither, which is the case for JSON cameras. + Names only. aiopppp's enums are transcribed from the vendor apps and are + incomplete, so a half it can't name (PTZA's chip 2) is dropped rather than + shown as a bare number -- "XR_PTZ". Returns None when neither half has a + name, including for JSON cameras, which report no type at all. The raw + numbers stay available on the device_type sensor's attributes. """ - dev_type = properties.get("devType") - chip_type = properties.get("chipType") - dev = properties.get("devTypeName") or ( - f"type {dev_type}" if dev_type is not None else None - ) - chip = properties.get("chipTypeName") or ( - f"chip {chip_type}" if chip_type is not None else None - ) - if dev and chip: - return f"{dev} ({chip})" - return dev or chip + names = [properties.get("devTypeName"), properties.get("chipTypeName")] + return "/".join(name for name in names if name) or None class PPPPBaseEntity(Entity): @@ -78,9 +70,11 @@ def device_info(self) -> DeviceInfo: camera_properties = self.device.device.properties return DeviceInfo( identifiers={(DOMAIN, self.device.dev_id)}, - # Device type and chip, e.g. "XR_PTZ (chip 2)". Falls back to the - # device id for cameras that report neither. - model=format_device_type(camera_properties) or self.device.dev_id, + model=self.device.dev_id, + # No real manufacturer is discoverable over PPPP, so the field is + # reused for the camera's type, e.g. "XR_PTZ/TX_817_810". None + # (leaving it unset) for cameras that report no named type. + manufacturer=format_device_type(camera_properties), model_id=camera_properties.get('sensor'), serial_number=self.device.dev_id, hw_version=camera_properties.get('mcuver'), diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index aed0641..b67d065 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -174,12 +174,17 @@ def _device_type_attrs(props: dict[str, Any]) -> dict[str, Any]: key="device_type", translation_key="device_type", entity_category=EntityCategory.DIAGNOSTIC, - # Off by default: the same string is already the device's Model, so + # Off by default: the same string is already on the device page, so # this exists for the raw numbers behind it, in the attributes. entity_registry_enabled_default=False, - # No poll_group -- a camera's model doesn't change. + # No poll_group -- a camera's type doesn't change. value_fn=format_device_type, - supported_fn=lambda props: format_device_type(props) is not None, + # Keyed on the raw values, not the rendered name: a camera whose type + # aiopppp can't name is exactly the one whose numbers are worth having. + # The state is then "unknown" while the attributes still carry them. + supported_fn=lambda props: ( + props.get("devType") is not None or props.get("chipType") is not None + ), attrs_fn=_device_type_attrs, ), ) From 3a46fd06e9abd084da9d7a51c925ff3a811f9226 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:11:29 +0300 Subject: [PATCH 36/42] Map device type to Model and chip type to Model ID model is devTypeName ("XR_PTZ"), falling back to the device id when aiopppp can't name the type. model_id is chipTypeName, with no fallback: an unnamed chip leaves the field unset rather than showing a bare number. JSON cameras report no chip but do report an image sensor, which keeps that slot. manufacturer goes back to unset -- it briefly held the same information, which is now in the two fields that mean it. The device_type sensor renders "DevType (ChipType)", or whichever half is named, or "Unknown" when neither is. Its attributes keep carrying all four raw values, so an unnamed type is still diagnosable -- that is what the sensor is for. The formatting helper moves to sensor.py now that the device info builds its fields straight from the properties. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- custom_components/pppp_camera/entity.py | 27 ++++++++----------------- custom_components/pppp_camera/sensor.py | 19 +++++++++++++++-- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index cdfe71b..88675fa 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ camera without an SD card gets no usage sensor. | Timezone | `sensor` | Disabled by default. Not created for firmwares that don't store one | | Signal strength | `sensor` | Wi-Fi RSSI in dBm. Disabled by default. Not created when the firmware reports no usable value | | SD card usage | `sensor` | Disabled by default. Only when a card is present | -| Device type | `sensor` | Disabled by default. Device and chip type, e.g. `XR_PTZ/TX_817_810` — the same string shown as the device's Manufacturer, with `devType`/`devTypeName`/`chipType`/`chipTypeName` as attributes | +| Device type | `sensor` | Disabled by default. `DevType (ChipType)`, e.g. `BK_A9 (TX_817_810)` — just the known half if only one is named, `Unknown` if neither. `devType`/`devTypeName`/`chipType`/`chipTypeName` as attributes | All sensors are diagnostic entities; the resolution select and the reboot/sync buttons are config entities. diff --git a/custom_components/pppp_camera/entity.py b/custom_components/pppp_camera/entity.py index 0d9a1d7..69e2cda 100644 --- a/custom_components/pppp_camera/entity.py +++ b/custom_components/pppp_camera/entity.py @@ -16,19 +16,6 @@ from .device import PPPPDevice -def format_device_type(properties: dict[str, Any]) -> str | None: - """Render the camera's type as "DevType/ChipType", e.g. "XR_PTZ/TX_817_810". - - Names only. aiopppp's enums are transcribed from the vendor apps and are - incomplete, so a half it can't name (PTZA's chip 2) is dropped rather than - shown as a bare number -- "XR_PTZ". Returns None when neither half has a - name, including for JSON cameras, which report no type at all. The raw - numbers stay available on the device_type sensor's attributes. - """ - names = [properties.get("devTypeName"), properties.get("chipTypeName")] - return "/".join(name for name in names if name) or None - - class PPPPBaseEntity(Entity): """Base class common to all PPPP entities.""" @@ -70,12 +57,14 @@ def device_info(self) -> DeviceInfo: camera_properties = self.device.device.properties return DeviceInfo( identifiers={(DOMAIN, self.device.dev_id)}, - model=self.device.dev_id, - # No real manufacturer is discoverable over PPPP, so the field is - # reused for the camera's type, e.g. "XR_PTZ/TX_817_810". None - # (leaving it unset) for cameras that report no named type. - manufacturer=format_device_type(camera_properties), - model_id=camera_properties.get('sensor'), + # Device type, e.g. "XR_PTZ", falling back to the device id for + # cameras whose type aiopppp can't name (or doesn't get told). + model=camera_properties.get('devTypeName') or self.device.dev_id, + # Chip type, e.g. "TX_817_810". No numeric fallback: an unnamed + # chip leaves this unset rather than showing a bare number, and the + # number is on the device_type sensor. JSON cameras report no chip + # at all but do report an image sensor, which is the same idea. + model_id=camera_properties.get('chipTypeName') or camera_properties.get('sensor'), serial_number=self.device.dev_id, hw_version=camera_properties.get('mcuver'), # The camera has no web UI (so a configuration_url "Visit" link is diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index b67d065..e73b945 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -24,7 +24,7 @@ from .const import DOMAIN, POLL_GROUP_INFO, POLL_GROUP_STATUS from .device import PPPPDevice -from .entity import PPPPBaseEntity, format_device_type +from .entity import PPPPBaseEntity def _first(props: dict[str, Any], *keys: str) -> Any: @@ -55,6 +55,21 @@ def _clock_attrs(props: dict[str, Any]) -> dict[str, Any]: return {"camera_time": camera_time.strftime("%Y-%m-%d %H:%M:%S")} if camera_time else {} +def _format_device_type(props: dict[str, Any]) -> str: + """Render the camera's type as "DevType (ChipType)", e.g. "BK_A9 (TX_817_810)". + + Names only. aiopppp's enums are transcribed from the vendor apps and are + incomplete, so a half it can't name is left out rather than shown as a bare + number: PTZA, whose chip 2 has no name, reads just "XR_PTZ". "Unknown" when + neither half has a name -- the raw numbers are in the attributes either way. + """ + dev = props.get("devTypeName") + chip = props.get("chipTypeName") + if dev and chip: + return f"{dev} ({chip})" + return dev or chip or "Unknown" + + def _device_type_attrs(props: dict[str, Any]) -> dict[str, Any]: """The raw halves behind the rendered model string. @@ -178,7 +193,7 @@ def _device_type_attrs(props: dict[str, Any]) -> dict[str, Any]: # this exists for the raw numbers behind it, in the attributes. entity_registry_enabled_default=False, # No poll_group -- a camera's type doesn't change. - value_fn=format_device_type, + value_fn=_format_device_type, # Keyed on the raw values, not the rendered name: a camera whose type # aiopppp can't name is exactly the one whose numbers are worth having. # The state is then "unknown" while the attributes still carry them. From e7672addcdc21c3dcb8f16f3d692c7b2256b2d17 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:22:54 +0300 Subject: [PATCH 37/42] Device type sensor: report unknown as None, not "Unknown" A literal "Unknown" is a real state value that merely looks like Home Assistant's unknown state, so states('sensor.x') would return "Unknown" where every other unknown sensor returns "unknown" and a template written the usual way would silently never match. None renders identically in the UI and carries the right semantics; the only cost is a gap in history, which is meaningless for a value that never changes. The attributes still carry all four raw values, so a camera whose type has no name is diagnosable even while the state reads unknown. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- custom_components/pppp_camera/sensor.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 88675fa..29ab3ea 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ camera without an SD card gets no usage sensor. | Timezone | `sensor` | Disabled by default. Not created for firmwares that don't store one | | Signal strength | `sensor` | Wi-Fi RSSI in dBm. Disabled by default. Not created when the firmware reports no usable value | | SD card usage | `sensor` | Disabled by default. Only when a card is present | -| Device type | `sensor` | Disabled by default. `DevType (ChipType)`, e.g. `BK_A9 (TX_817_810)` — just the known half if only one is named, `Unknown` if neither. `devType`/`devTypeName`/`chipType`/`chipTypeName` as attributes | +| Device type | `sensor` | Disabled by default. `DevType (ChipType)`, e.g. `BK_A9 (TX_817_810)` — just the known half if only one is named, unknown if neither. `devType`/`devTypeName`/`chipType`/`chipTypeName` as attributes | All sensors are diagnostic entities; the resolution select and the reboot/sync buttons are config entities. diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index e73b945..40ccf76 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -55,19 +55,24 @@ def _clock_attrs(props: dict[str, Any]) -> dict[str, Any]: return {"camera_time": camera_time.strftime("%Y-%m-%d %H:%M:%S")} if camera_time else {} -def _format_device_type(props: dict[str, Any]) -> str: +def _format_device_type(props: dict[str, Any]) -> str | None: """Render the camera's type as "DevType (ChipType)", e.g. "BK_A9 (TX_817_810)". Names only. aiopppp's enums are transcribed from the vendor apps and are incomplete, so a half it can't name is left out rather than shown as a bare - number: PTZA, whose chip 2 has no name, reads just "XR_PTZ". "Unknown" when - neither half has a name -- the raw numbers are in the attributes either way. + number: PTZA, whose chip 2 has no name, reads just "XR_PTZ". + + None when neither half has a name, which Home Assistant renders as + "Unknown" -- rather than a literal "Unknown" string, which would look the + same but be a real value, so templates comparing against the usual + "unknown" state would silently never match. The raw numbers stay in the + attributes either way, which is what makes such a camera diagnosable. """ dev = props.get("devTypeName") chip = props.get("chipTypeName") if dev and chip: return f"{dev} ({chip})" - return dev or chip or "Unknown" + return dev or chip or None def _device_type_attrs(props: dict[str, Any]) -> dict[str, Any]: From 0ba330dbceafc65075f9b1769c6db60799ec4174 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:10:21 +0300 Subject: [PATCH 38/42] Mark every lamp assumed-state, including the ones that report Cameras that populate the function bitmap dropped assumed_state, which gave them a real toggle while the rest kept the two-button control -- so two cameras side by side looked like different kinds of entity. Worse than the inconsistency, it overstated what we know. These firmwares have repeatedly turned out to carry status fields that look populated but are not: PTZA parks `icut` at 1 whatever the IR is doing and leaves its whole powerSupply word at zero, and the sysUptime field was really Wi-Fi dBm. A model we haven't tested could report a lamp state that is quietly wrong in the same way, and a toggle claims a certainty we don't have. The reading itself is still used where a camera provides one -- seeding, the status poll and live correction are unchanged, so a change made from the vendor app still shows up. Only the UI affordance is uniform now. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- custom_components/pppp_camera/entity.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 29ab3ea..5b6d869 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ camera without an SD card gets no usage sensor. | Entity | Platform | Notes | |:-------|:---------|:------| | Camera | `camera` | Live stream, snapshots, and turn on/off (starts and stops the video stream) | -| White Lamp / IR Lamp | `switch`, `light` or `button` | Only for cameras reporting that lamp. The platform is chosen by the `platform.lamp` option. Cameras that report real lamp state (function bitmap in the status block) track it live, so changes made from the vendor app show up; the rest assume their own writes | +| White Lamp / IR Lamp | `switch`, `light` or `button` | Only for cameras reporting that lamp. The platform is chosen by the `platform.lamp` option. Cameras that report real lamp state (function bitmap in the status block) track it live, so changes made from the vendor app show up; the rest assume their own writes. All lamps are marked assumed-state either way — these cameras report fields that look populated but aren't often enough that a toggle would overstate what we know | | Reboot | `button` | Only when logged in — the camera refuses it otherwise | | Sync time | `button` | Binary-protocol cameras only | | Resolution | `select` | Binary-protocol cameras only. QVGA / VGA / HD / FD / UD | diff --git a/custom_components/pppp_camera/entity.py b/custom_components/pppp_camera/entity.py index 69e2cda..e535d9e 100644 --- a/custom_components/pppp_camera/entity.py +++ b/custom_components/pppp_camera/entity.py @@ -81,10 +81,18 @@ class PPPPLampEntity(PPPPBaseEntity): Cameras whose status block populates the function bitmap report real lamp state (confirmed on FTYC): those entities follow the status poll, so a change made from the vendor app shows up here. The rest can only be - assumed, and keep the previous behaviour of remembering what we last sent. + assumed, and remember what we last sent instead. """ _attr_has_entity_name = True + # Assumed on every camera, including the ones that do report a reading. + # These are cheap devices whose status block has repeatedly turned out to + # carry fields that look populated but aren't -- PTZA's `icut` sits at 1 + # whatever the IR does, and its whole powerSupply word reads zero -- so a + # firmware we haven't tested could just as easily report a lamp state that + # is quietly wrong. A toggle would claim a certainty we don't have, and it + # also keeps every camera's controls looking the same. + _attr_assumed_state = True def __init__(self, device: PPPPDevice, description) -> None: """Initialize the lamp.""" @@ -99,7 +107,6 @@ def __init__(self, device: PPPPDevice, description) -> None: else None ) self._reports_state = reported is not None - self._attr_assumed_state = not self._reports_state if self._reports_state: self._attr_is_on = bool(reported) From b7568575ebab55ff639d56febf7426f397cea86c Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:26:17 +0300 Subject: [PATCH 39/42] Add a human-readable clock offset sensor "-17,579 s" is hard to read as nearly five hours. Add a second sensor rendering the same value as "-4 h 52 m 59 s", dropping empty units so a small offset stays "12 s", and including days because a camera with a wrong date rather than a wrong clock shows up here as a huge number. A separate entity rather than formatting the existing one: an entity's state IS what Home Assistant displays, so there is no display-only formatting, and clock_offset has to stay a plain number for templates, automations and statistics. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + custom_components/pppp_camera/sensor.py | 37 +++++++++++++++++++ .../pppp_camera/translations/en.json | 3 ++ 3 files changed, 41 insertions(+) diff --git a/README.md b/README.md index 5b6d869..a5780cf 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ camera without an SD card gets no usage sensor. | Battery | `sensor` | Only when the camera reports a real battery voltage | | Power source | `sensor` | External or Battery. Only alongside a battery reading — mains-only cameras leave the field unpopulated rather than reporting "external" | | Clock offset | `sensor` | Seconds the camera clock is ahead (+) or behind (−) Home Assistant, with the raw camera time as an attribute | +| Clock offset (formatted) | `sensor` | The same offset written for people, e.g. `-4 h 52 m 59 s`. Separate entity because a state is what HA displays — `Clock offset` stays a plain number for templates and statistics | | Wi-Fi network | `sensor` | SSID the camera is joined to | | Timezone | `sensor` | Disabled by default. Not created for firmwares that don't store one | | Signal strength | `sensor` | Wi-Fi RSSI in dBm. Disabled by default. Not created when the firmware reports no usable value | diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index 40ccf76..fb71c34 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -75,6 +75,30 @@ def _format_device_type(props: dict[str, Any]) -> str | None: return dev or chip or None +def _format_offset(seconds: Any) -> str | None: + """Render a signed second count as "-4 h 52 m 59 s". + + Empty units are dropped, so a small offset reads "12 s" rather than + "0 d 0 h 0 m 12 s". Days are included because a camera with a wrong date -- + not just a wrong clock -- shows up here as a very large number. + """ + if seconds is None: + return None + seconds = int(seconds) + sign = "-" if seconds < 0 else "" + days, rest = divmod(abs(seconds), 86400) + hours, rest = divmod(rest, 3600) + minutes, secs = divmod(rest, 60) + parts = [ + f"{value} {unit}" + for value, unit in ((days, "d"), (hours, "h"), (minutes, "m")) + if value + ] + if secs or not parts: + parts.append(f"{secs} s") + return sign + " ".join(parts) + + def _device_type_attrs(props: dict[str, Any]) -> dict[str, Any]: """The raw halves behind the rendered model string. @@ -182,6 +206,19 @@ def _device_type_attrs(props: dict[str, Any]) -> dict[str, Any]: supported_fn=lambda props: props.get("clock_offset") is not None, attrs_fn=_clock_attrs, ), + PPPPSensorEntityDescription( + key="clock_offset_text", + translation_key="clock_offset_text", + poll_group=POLL_GROUP_INFO, + entity_category=EntityCategory.DIAGNOSTIC, + # The same offset written for people: "-4 h 52 m 59 s" instead of + # "-17,579 s". A separate entity because a state IS what Home Assistant + # displays -- there is no display-only formatting -- and clock_offset + # must stay a plain number for templates, automations and statistics. + value_fn=lambda props: _format_offset(props.get("clock_offset")), + supported_fn=lambda props: props.get("clock_offset") is not None, + attrs_fn=_clock_attrs, + ), PPPPSensorEntityDescription( key="ssid", translation_key="ssid", diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index 2cd552b..dc70f1c 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -56,6 +56,9 @@ "clock_offset": { "name": "Clock offset" }, + "clock_offset_text": { + "name": "Clock offset (formatted)" + }, "device_type": { "name": "Device type" } From 5a14380a51b204140b901447dea61df4e0af0083 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:34:11 +0300 Subject: [PATCH 40/42] Clock offset: one sensor, readable state, number in an attribute Replaces the pair added in the previous commit. The state is now the human-readable form ("-4 h 52 m 59 s") since that is what Home Assistant displays, and the plain number moved to an `offset_seconds` attribute for templates and automations. The unit and state_class had to go with it: both declare a numeric state, and HA logs an error on every update when the state isn't one. That also costs long-term statistics for this value, which is an acceptable trade for a diagnostic that is read rather than graphed. Note for anyone upgrading: automations reading this entity's state get text now and should move to the offset_seconds attribute. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 3 +- custom_components/pppp_camera/sensor.py | 35 +++++++++---------- .../pppp_camera/translations/en.json | 3 -- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index a5780cf..d7fe86e 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,7 @@ camera without an SD card gets no usage sensor. | Resolution | `select` | Binary-protocol cameras only. QVGA / VGA / HD / FD / UD | | Battery | `sensor` | Only when the camera reports a real battery voltage | | Power source | `sensor` | External or Battery. Only alongside a battery reading — mains-only cameras leave the field unpopulated rather than reporting "external" | -| Clock offset | `sensor` | Seconds the camera clock is ahead (+) or behind (−) Home Assistant, with the raw camera time as an attribute | -| Clock offset (formatted) | `sensor` | The same offset written for people, e.g. `-4 h 52 m 59 s`. Separate entity because a state is what HA displays — `Clock offset` stays a plain number for templates and statistics | +| Clock offset | `sensor` | How far the camera clock is ahead (+) or behind (−) Home Assistant, as readable text (`-4 h 52 m 59 s`). Attributes: `offset_seconds` — the plain number, for templates and automations — and `camera_time` | | Wi-Fi network | `sensor` | SSID the camera is joined to | | Timezone | `sensor` | Disabled by default. Not created for firmwares that don't store one | | Signal strength | `sensor` | Wi-Fi RSSI in dBm. Disabled by default. Not created when the firmware reports no usable value | diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index fb71c34..52e124f 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -17,7 +17,6 @@ PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, - UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -50,9 +49,15 @@ class PPPPSensorEntityDescription(SensorEntityDescription): def _clock_attrs(props: dict[str, Any]) -> dict[str, Any]: - """Show the reading the offset was derived from.""" - camera_time = props.get("camera_time") - return {"camera_time": camera_time.strftime("%Y-%m-%d %H:%M:%S")} if camera_time else {} + """The raw offset, plus the reading it was derived from. + + The state is human-readable text, so `offset_seconds` is what templates and + automations should use. + """ + attrs: dict[str, Any] = {"offset_seconds": props.get("clock_offset")} + if camera_time := props.get("camera_time"): + attrs["camera_time"] = camera_time.strftime("%Y-%m-%d %H:%M:%S") + return attrs def _format_device_type(props: dict[str, Any]) -> str | None: @@ -194,27 +199,19 @@ def _device_type_attrs(props: dict[str, Any]) -> dict[str, Any]: key="clock_offset", translation_key="clock_offset", poll_group=POLL_GROUP_INFO, - native_unit_of_measurement=UnitOfTime.SECONDS, - state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, # How far the camera clock is ahead (+) or behind (-) Home Assistant. # Reported instead of the camera's time itself: the offset answers the # question the sensor exists for ("is the clock right?"), stays put # between readings, and can't drift into a plausible-looking lie the # way a locally-advanced clock could. - value_fn=lambda props: props.get("clock_offset"), - supported_fn=lambda props: props.get("clock_offset") is not None, - attrs_fn=_clock_attrs, - ), - PPPPSensorEntityDescription( - key="clock_offset_text", - translation_key="clock_offset_text", - poll_group=POLL_GROUP_INFO, - entity_category=EntityCategory.DIAGNOSTIC, - # The same offset written for people: "-4 h 52 m 59 s" instead of - # "-17,579 s". A separate entity because a state IS what Home Assistant - # displays -- there is no display-only formatting -- and clock_offset - # must stay a plain number for templates, automations and statistics. + # + # The state is human-readable ("-4 h 52 m 59 s"), because a state is + # what Home Assistant displays and "-17,579 s" doesn't read as nearly + # five hours. The number lives in the `offset_seconds` attribute, which + # is what templates and automations should use. That also rules out a + # unit and a state_class: both declare a numeric state, and HA logs an + # error for every update if the state isn't one. value_fn=lambda props: _format_offset(props.get("clock_offset")), supported_fn=lambda props: props.get("clock_offset") is not None, attrs_fn=_clock_attrs, diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json index dc70f1c..2cd552b 100644 --- a/custom_components/pppp_camera/translations/en.json +++ b/custom_components/pppp_camera/translations/en.json @@ -56,9 +56,6 @@ "clock_offset": { "name": "Clock offset" }, - "clock_offset_text": { - "name": "Clock offset (formatted)" - }, "device_type": { "name": "Device type" } From 38d9088ac93066af5fae9bb90876a72b602f894f Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:46:19 +0300 Subject: [PATCH 41/42] Clock offset: use the duration device class, keep the number Restores the numeric state and puts the words in an offset_text attribute, which is the inverse of the previous commit -- and adds device_class=DURATION, which is what makes that work. DURATION keeps the state a plain number, so templates, automations and long-term statistics all come back, while letting Home Assistant convert the displayed unit per entity: a pathological offset can be read in hours without the integration hard-coding a unit. Seconds stays the default because a healthy clock is off by seconds, and hours would render those as "-0.0 h". Not timestamp: that renders relative to now, so a perfectly synced camera would appear to fall further behind the longer it had been since the last poll -- the apparent offset would track the poll interval rather than the camera. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- custom_components/pppp_camera/sensor.py | 31 +++++++++++++++---------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d7fe86e..5238179 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ camera without an SD card gets no usage sensor. | Resolution | `select` | Binary-protocol cameras only. QVGA / VGA / HD / FD / UD | | Battery | `sensor` | Only when the camera reports a real battery voltage | | Power source | `sensor` | External or Battery. Only alongside a battery reading — mains-only cameras leave the field unpopulated rather than reporting "external" | -| Clock offset | `sensor` | How far the camera clock is ahead (+) or behind (−) Home Assistant, as readable text (`-4 h 52 m 59 s`). Attributes: `offset_seconds` — the plain number, for templates and automations — and `camera_time` | +| Clock offset | `sensor` | Seconds the camera clock is ahead (+) or behind (−) Home Assistant. A `duration` sensor, so the displayed unit can be changed per entity (seconds → hours) in its settings. Attributes: `offset_text` (`-4 h 52 m 59 s`) and `camera_time` | | Wi-Fi network | `sensor` | SSID the camera is joined to | | Timezone | `sensor` | Disabled by default. Not created for firmwares that don't store one | | Signal strength | `sensor` | Wi-Fi RSSI in dBm. Disabled by default. Not created when the firmware reports no usable value | diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index 52e124f..4cb8c12 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -17,6 +17,7 @@ PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -49,12 +50,14 @@ class PPPPSensorEntityDescription(SensorEntityDescription): def _clock_attrs(props: dict[str, Any]) -> dict[str, Any]: - """The raw offset, plus the reading it was derived from. + """The offset in words, plus the reading it was derived from. - The state is human-readable text, so `offset_seconds` is what templates and - automations should use. + Seconds is the right display unit while a clock is roughly correct, which + is the normal case, but it reads badly once an offset runs to hours -- + "-17,579 s". `offset_text` spells that one out without costing the state + its numeric type. """ - attrs: dict[str, Any] = {"offset_seconds": props.get("clock_offset")} + attrs: dict[str, Any] = {"offset_text": _format_offset(props.get("clock_offset"))} if camera_time := props.get("camera_time"): attrs["camera_time"] = camera_time.strftime("%Y-%m-%d %H:%M:%S") return attrs @@ -199,20 +202,24 @@ def _device_type_attrs(props: dict[str, Any]) -> dict[str, Any]: key="clock_offset", translation_key="clock_offset", poll_group=POLL_GROUP_INFO, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, # How far the camera clock is ahead (+) or behind (-) Home Assistant. # Reported instead of the camera's time itself: the offset answers the # question the sensor exists for ("is the clock right?"), stays put # between readings, and can't drift into a plausible-looking lie the - # way a locally-advanced clock could. + # way a locally-advanced clock could. That also rules out the timestamp + # device class, which renders relative to *now*: a perfectly synced + # camera would appear to fall further behind between polls. # - # The state is human-readable ("-4 h 52 m 59 s"), because a state is - # what Home Assistant displays and "-17,579 s" doesn't read as nearly - # five hours. The number lives in the `offset_seconds` attribute, which - # is what templates and automations should use. That also rules out a - # unit and a state_class: both declare a numeric state, and HA logs an - # error for every update if the state isn't one. - value_fn=lambda props: _format_offset(props.get("clock_offset")), + # DURATION keeps the state a plain number -- templates, automations and + # statistics all work -- while letting Home Assistant convert the + # displayed unit per entity, so a large offset can be read in hours + # instead of seconds. Seconds stays the default because a healthy clock + # is off by seconds, where hours would render as "-0.0 h". + value_fn=lambda props: props.get("clock_offset"), supported_fn=lambda props: props.get("clock_offset") is not None, attrs_fn=_clock_attrs, ), From 0c40f04b12a84ddd232304d70fb09b0ef1ab8298 Mon Sep 17 00:00:00 2001 From: LN <46201360+DevLn@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:15:10 +0300 Subject: [PATCH 42/42] Clock offset: display whole seconds The value is an int, but a convertible unit makes Home Assistant render decimals by default, so a three-second offset showed as "3.00 s". Suggest a display precision of 0; it stays overridable per entity, and HA scales it when the displayed unit is converted. Co-Authored-By: Claude Opus 5 (1M context) --- custom_components/pppp_camera/sensor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/custom_components/pppp_camera/sensor.py b/custom_components/pppp_camera/sensor.py index 4cb8c12..1696565 100644 --- a/custom_components/pppp_camera/sensor.py +++ b/custom_components/pppp_camera/sensor.py @@ -205,6 +205,11 @@ def _device_type_attrs(props: dict[str, Any]) -> dict[str, Any]: device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.MEASUREMENT, + # Whole seconds: the value is an int to begin with, and a convertible + # unit makes Home Assistant render decimals by default ("3.00 s"). + # Only a default -- the precision is overridable per entity, and HA + # scales it when the displayed unit is converted. + suggested_display_precision=0, entity_category=EntityCategory.DIAGNOSTIC, # How far the camera clock is ahead (+) or behind (-) Home Assistant. # Reported instead of the camera's time itself: the offset answers the