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 +}