diff --git a/README.md b/README.md
index 00f9539..5238179 100644
--- a/README.md
+++ b/README.md
@@ -7,25 +7,37 @@ 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) — see
+ [Known issues](#known-issues)
+- 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
+ 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
-- (TBD) Sound streaming
+- 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
## 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 +47,85 @@ 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 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
+
+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. 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 |
+| 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. 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 |
+| 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 |
+
+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, 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
+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 +146,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:
@@ -77,6 +172,9 @@ 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
+ status_poll_interval: 300 # seconds between battery/signal/SD refreshes
+ info_poll_interval: 3600 # seconds between clock/SSID refreshes
```
### Configuration Parameters
@@ -106,6 +204,31 @@ 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.
+
+#### `status_poll_interval` (optional)
+
+- **`status_poll_interval`** (integer, default: `300`): Seconds between refreshes
+ 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.
+
+#### `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
@@ -123,6 +246,24 @@ 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
+```
+
+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:
@@ -164,6 +305,21 @@ 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.
+- **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.
+- **Missing sensors?** Most are conditional (see [Entities](#entities)), and
+ 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
+ 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/__init__.py b/custom_components/pppp_camera/__init__.py
index d2efd0e..724a22f 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,
@@ -15,17 +14,26 @@
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
+from .config_helpers import get_config
from .const import (
DOMAIN,
+ LOGGER,
PLATFORMS,
CONF_DEFAULTS,
CONF_IP,
CONF_DURATION,
CONF_INTERVAL,
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,
)
@@ -56,6 +64,18 @@
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)),
+ vol.Optional(
+ CONF_STATUS_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)),
}
)
},
@@ -81,6 +101,15 @@
# 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)
+ 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)
+
+All of these can also be set per camera in the integration's options, which
+takes precedence over the values here.
"""
@@ -89,9 +118,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)
@@ -105,10 +134,14 @@ 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:
- await device.device.close()
+ 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.
+ # 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
@@ -118,12 +151,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
Platform.BUTTON,
Platform.LIGHT,
Platform.SWITCH,
+ Platform.SENSOR,
+ Platform.SELECT,
]
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)
)
@@ -132,9 +167,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/brand/icon.png b/custom_components/pppp_camera/brand/icon.png
new file mode 100644
index 0000000..a21283b
Binary files /dev/null and b/custom_components/pppp_camera/brand/icon.png differ
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 0000000..641d73a
Binary files /dev/null and b/custom_components/pppp_camera/brand/icon@2x.png differ
diff --git a/custom_components/pppp_camera/button.py b/custom_components/pppp_camera/button.py
index 1711713..78c1013 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,6 +33,9 @@ class PPPPButtonEntityDescription(ButtonEntityDescription):
translation_key="reboot",
press_fn=lambda device: device.async_reboot,
press_data=None,
+ # 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,
@@ -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,9 +53,19 @@ 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"
),
+ 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 15cb93c..b20495a 100644
--- a/custom_components/pppp_camera/camera.py
+++ b/custom_components/pppp_camera/camera.py
@@ -8,15 +8,28 @@
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.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
-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,
DIR_DOWN,
DIR_LEFT,
@@ -24,7 +37,10 @@
DIR_UP,
DOMAIN,
LOGGER,
+ PRESET_ACTION_GOTO,
+ PRESET_ACTION_SET,
SERVICE_PTZ,
+ SERVICE_PTZ_PRESET,
# ATTR_MOVE_MODE,
# RELATIVE_MOVE,
# CONTINUOUS_MOVE,
@@ -32,8 +48,8 @@
# GOTOPRESET_MOVE,
# STOP_MOVE,
# ATTR_CONTINUOUS_DURATION,
- # ATTR_PRESET,
SERVICE_REBOOT,
+ SERVICE_TALK,
)
from .device import PPPPDevice
from .entity import PPPPBaseEntity
@@ -41,6 +57,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,
@@ -74,11 +99,26 @@ 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,
"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)])
@@ -105,8 +145,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 +156,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:
@@ -137,7 +211,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()
@@ -182,7 +255,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,
@@ -197,9 +272,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(
@@ -214,8 +291,32 @@ 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:
- """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)
+
+ 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_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/config_flow.py b/custom_components/pppp_camera/config_flow.py
index c9c56fa..afb5d43 100644
--- a/custom_components/pppp_camera/config_flow.py
+++ b/custom_components/pppp_camera/config_flow.py
@@ -29,8 +29,20 @@
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,
+ 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,
+)
@callback
@@ -70,6 +82,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 +95,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 +270,53 @@ 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)
+ )
+ 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(
+ CONF_IDLE_DISCONNECT_DELAY, default=current_delay
+ ): selector.NumberSelector(
+ selector.NumberSelectorConfig(
+ 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
+ )
+ ),
+ 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)
class InvalidAuth(HomeAssistantError):
diff --git a/custom_components/pppp_camera/config_helpers.py b/custom_components/pppp_camera/config_helpers.py
index 97d4496..37b8c71 100644
--- a/custom_components/pppp_camera/config_helpers.py
+++ b/custom_components/pppp_camera/config_helpers.py
@@ -2,10 +2,20 @@
from typing import Any
+from homeassistant.config_entries import ConfigEntry
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,
+ CONF_INFO_POLL_INTERVAL,
+ CONF_STATUS_POLL_INTERVAL,
+ DEFAULT_IDLE_DISCONNECT_DELAY,
+ DEFAULT_INFO_POLL_INTERVAL,
+ DEFAULT_STATUS_POLL_INTERVAL,
+ DOMAIN,
+)
def get_config(hass: HomeAssistant) -> dict[str, Any]:
@@ -23,3 +33,47 @@ 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, 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
+ )
+
+
+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-block refreshes (0 disables polling)."""
+ return _get_interval(
+ hass, config_entry, 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 f80e599..63e1169 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,7 +30,11 @@
DIR_RIGHT = "RIGHT"
SERVICE_PTZ = "ptz"
+SERVICE_PTZ_PRESET = "ptz_preset"
SERVICE_REBOOT = "reboot"
+SERVICE_TALK = "talk"
+
+ATTR_MEDIA = "media"
SOURCE_DISCOVERY_CONFIRM = "discovery_confirm"
@@ -35,3 +43,49 @@
CONF_DURATION = "duration"
CONF_INTERVAL = "interval"
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 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
+# command has been delivered. 0 disconnects immediately.
+DEFAULT_IDLE_DISCONNECT_DELAY = 5
+
+# 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.
+#
+# 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
+
+# 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 6de2342..ebbe98a 100644
--- a/custom_components/pppp_camera/device.py
+++ b/custom_components/pppp_camera/device.py
@@ -4,6 +4,10 @@
import asyncio
import contextlib
+import datetime as dt
+import time
+from collections import deque
+from collections.abc import Callable
import aiopppp
from homeassistant.config_entries import ConfigEntry
@@ -13,7 +17,23 @@
CONF_USERNAME,
Platform,
)
-from homeassistant.core import HomeAssistant
+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,
+ get_info_poll_interval,
+ get_status_poll_interval,
+)
+from .const import (
+ DOMAIN,
+ LOGGER,
+ POLL_GROUP_INFO,
+ POLL_GROUP_STATUS,
+ SYNC_READBACK_DELAY,
+)
class PPPPDevice:
@@ -28,11 +48,41 @@ 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
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, config_entry)
+ self._status_poll_interval: int = get_status_poll_interval(hass, config_entry)
+ 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"
+ 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."""
+ if self.available != value:
+ 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:
@@ -61,21 +111,54 @@ 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:
+ 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):
- """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)
+ 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
- self._connected_num -= 1
- if self._connected_num == 0:
- await asyncio.sleep(1);
- await self.device.close()
+ 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."""
@@ -84,10 +167,15 @@ 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():
self.info = self.device.properties
+ await self._async_fetch_extra_info()
+
+ 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)
@@ -95,7 +183,208 @@ async def async_setup(self) -> None:
async def async_stop(self, event=None):
"""Shut it all down."""
- await self.device.close()
+ self._stop_polling()
+ async with self._lock:
+ self._cancel_idle_unload()
+ 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 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
+ # 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:
+ decoded = parse_datetime_block(await get_datetime(timeout=4))
+ if local := decoded.get("local"):
+ 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
+ # 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)
+
+ 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)
+
+ # 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 self._is_streaming and (value := await self._async_read_resolution()) is not None:
+ info["resolution"] = value
+
+ 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."""
+ 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():
+ 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, 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
+ 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)
+
+ @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(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: %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_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."""
+ 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."""
@@ -128,6 +417,124 @@ 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_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.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:
+ pcm = await proc.stdout.read(chunk_bytes)
+ 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:
+ 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)
+ 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."""
+ 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")
+ # 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.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 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.
+ 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 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)
+ self.extra_info["camera_time_read_at"] = time.monotonic()
+ self.extra_info["clock_offset"] = 0
+ async_dispatcher_send(self.hass, self.signal_available)
+
@contextlib.asynccontextmanager
async def ensure_connected(self):
@@ -425,10 +832,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,
)
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
diff --git a/custom_components/pppp_camera/entity.py b/custom_components/pppp_camera/entity.py
index 2fb8537..e535d9e 100644
--- a/custom_components/pppp_camera/entity.py
+++ b/custom_components/pppp_camera/entity.py
@@ -1,19 +1,50 @@
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
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
+ async def async_added_to_hass(self) -> None:
+ """Refresh state when the device's data or availability changes."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ 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."""
@@ -26,8 +57,98 @@ def device_info(self) -> DeviceInfo:
camera_properties = self.device.device.properties
return DeviceInfo(
identifiers={(DOMAIN, self.device.dev_id)},
+ # 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'),
- sw_version=camera_properties.get('sysver'),
- model=self.device.dev_id,
- model_id=camera_properties.get('sensor'),
+ # 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,
+ )
+
+
+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 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."""
+ 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
+
+ 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 1dd0bda..f90d736 100644
--- a/custom_components/pppp_camera/light.py
+++ b/custom_components/pppp_camera/light.py
@@ -12,9 +12,9 @@
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 .entity import PPPPLampEntity
from .config_helpers import get_platform_config
@@ -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, ...] = (
@@ -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",
),
)
@@ -72,36 +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
-
- def __init__(
- self, device: PPPPDevice, description: PPPPLightEntityDescription
- ) -> None:
- """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
-
- 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
- )
-
- 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
- )
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"
}
diff --git a/custom_components/pppp_camera/select.py b/custom_components/pppp_camera/select.py
new file mode 100644
index 0000000..ed5a96d
--- /dev/null
+++ b/custom_components/pppp_camera/select.py
@@ -0,0 +1,113 @@
+"""Configuration selects for PPPP cameras."""
+
+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, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+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",
+ 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"
+ 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).
+
+ 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]
+ 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
new file mode 100644
index 0000000..1696565
--- /dev/null
+++ b/custom_components/pppp_camera/sensor.py
@@ -0,0 +1,319 @@
+"""PPPP diagnostic sensors (battery, signal, power source, SD usage)."""
+
+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, POLL_GROUP_INFO, POLL_GROUP_STATUS
+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]
+ # 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 _clock_attrs(props: dict[str, Any]) -> dict[str, Any]:
+ """The offset in words, plus the reading it was derived from.
+
+ 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_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
+
+
+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".
+
+ 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 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.
+
+ 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",
+ translation_key="battery",
+ poll_group=POLL_GROUP_STATUS,
+ device_class=SensorDeviceClass.BATTERY,
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ # JSON cameras report batValue (percent). Binary cameras report
+ # 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"),
+ supported_fn=lambda props: _first(props, "batValue", "batPercent") is not None,
+ ),
+ 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,
+ 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="power_source",
+ translation_key="power_source",
+ poll_group=POLL_GROUP_STATUS,
+ device_class=SensorDeviceClass.ENUM,
+ options=["external", "battery"],
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda props: (
+ "external" if props.get("externalPower") else "battery"
+ ),
+ # 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",
+ translation_key="sd_usage",
+ poll_group=POLL_GROUP_STATUS,
+ 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,
+ # 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"),
+ supported_fn=lambda props: bool(props.get("tz")),
+ ),
+ PPPPSensorEntityDescription(
+ 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,
+ # 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
+ # 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. 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.
+ #
+ # 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,
+ ),
+ 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")),
+ ),
+ PPPPSensorEntityDescription(
+ key="device_type",
+ translation_key="device_type",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ # 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 type doesn't change.
+ 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.
+ supported_fn=lambda props: (
+ props.get("devType") is not None or props.get("chipType") is not None
+ ),
+ attrs_fn=_device_type_attrs,
+ ),
+)
+
+
+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 = _properties(device)
+ async_add_entities(
+ PPPPSensor(device, description)
+ for description in SENSORS
+ if description.supported_fn(props)
+ )
+
+
+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.
+
+ 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}"
+
+ 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)
+ )
+
+ @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/services.yaml b/custom_components/pppp_camera/services.yaml
index 3af9728..b3d00a1 100644
--- a/custom_components/pppp_camera/services.yaml
+++ b/custom_components/pppp_camera/services.yaml
@@ -3,6 +3,38 @@ 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:
+ 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/switch.py b/custom_components/pppp_camera/switch.py
index ed91f1d..32c4e41 100644
--- a/custom_components/pppp_camera/switch.py
+++ b/custom_components/pppp_camera/switch.py
@@ -12,9 +12,9 @@
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 .entity import PPPPLampEntity
from .config_helpers import get_platform_config
@@ -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",
),
)
@@ -72,33 +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
-
- def __init__(
- self, device: PPPPDevice, description: PPPPSwitchEntityDescription
- ) -> None:
- """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
-
- 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
- )
-
- 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
- )
diff --git a/custom_components/pppp_camera/translations/en.json b/custom_components/pppp_camera/translations/en.json
index 8d9be75..2cd552b 100644
--- a/custom_components/pppp_camera/translations/en.json
+++ b/custom_components/pppp_camera/translations/en.json
@@ -25,6 +25,51 @@
},
"ir_lamp": {
"name": "IR Lamp"
+ },
+ "sync_time": {
+ "name": "Sync time"
+ }
+ },
+ "sensor": {
+ "battery": {
+ "name": "Battery"
+ },
+ "signal": {
+ "name": "Signal strength"
+ },
+ "power_source": {
+ "name": "Power source",
+ "state": {
+ "external": "External",
+ "battery": "Battery"
+ }
+ },
+ "sd_usage": {
+ "name": "SD card usage"
+ },
+ "timezone": {
+ "name": "Timezone"
+ },
+ "ssid": {
+ "name": "Wi-Fi network"
+ },
+ "clock_offset": {
+ "name": "Clock offset"
+ },
+ "device_type": {
+ "name": "Device type"
+ }
+ },
+ "select": {
+ "resolution": {
+ "name": "Resolution",
+ "state": {
+ "qvga": "QVGA",
+ "vga": "VGA",
+ "hd": "HD",
+ "fd": "FD",
+ "ud": "UD"
+ }
}
},
"camera": {
@@ -53,5 +98,66 @@
"description": "{name}"
}
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Camera options",
+ "data": {
+ "idle_disconnect_delay": "Idle disconnect delay (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, 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."
+ }
+ }
+ }
+ },
+ "services": {
+ "reboot": {
+ "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.",
+ "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
+}