Initial upload
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Custom integration: camera_record_services (fork-v1-rev11)
|
||||
|
||||
Converted from pyscript script fork-v1-rev11. Provides two services
|
||||
callable from automations:
|
||||
|
||||
camera_record_services.camera_start_recording
|
||||
entity_id: camera.my_camera # required
|
||||
filename: /media/clip.mp4 # required — plain string, template on the HA side
|
||||
duration: 30 # optional, seconds, default 30
|
||||
lookback: 0 # optional, seconds, default 0
|
||||
stop_state_id: binary_sensor.motion # optional — entity to watch for early stop
|
||||
stop_state_value: "off" # optional — value that triggers early stop
|
||||
# stop_state_id and stop_state_value must be
|
||||
# provided together or not at all
|
||||
|
||||
camera_record_services.camera_stop_recording
|
||||
entity_id: camera.my_camera # required
|
||||
|
||||
Both services are best-effort. camera_stop_recording will silently no-op if
|
||||
no recording is in progress or if the stream does not exist yet.
|
||||
|
||||
No custom state entities are created or maintained by this integration.
|
||||
|
||||
Installation
|
||||
------------
|
||||
Place this directory at:
|
||||
<config>/custom_components/camera_record_services/
|
||||
|
||||
Then restart HA and add the integration via Settings → Integrations → Add Integration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
# The string value of stream.const.RECORDER_PROVIDER.
|
||||
# We import it at runtime so that if the constant is ever renamed we get a
|
||||
# clean ImportError rather than a silent wrong-key miss. The fallback keeps
|
||||
# things working on older HA versions that already had the string as "recorder".
|
||||
try:
|
||||
from homeassistant.components.stream.const import RECORDER_PROVIDER as _RECORDER_PROVIDER
|
||||
except ImportError:
|
||||
_RECORDER_PROVIDER = "recorder"
|
||||
|
||||
|
||||
# Minimum valid recording file size in bytes. Files smaller than this contain
|
||||
# only the MP4 container header with no actual video data — indicating the
|
||||
# stream failed to capture anything meaningful.
|
||||
_MIN_RECORDING_SIZE = 1024
|
||||
|
||||
|
||||
def _get_camera(hass, entity_id):
|
||||
"""Return the Camera entity object, or None if not found."""
|
||||
component = hass.data.get("camera")
|
||||
if component is None:
|
||||
_LOGGER.error("camera_record_services: camera component not loaded")
|
||||
return None
|
||||
camera = component.get_entity(entity_id)
|
||||
if camera is None:
|
||||
_LOGGER.error(
|
||||
"camera_record_services: entity '%s' not found in camera component",
|
||||
entity_id,
|
||||
)
|
||||
return camera
|
||||
|
||||
|
||||
async def _check_recording_file(filename):
|
||||
"""Validate that a recording file exists and contains real video data.
|
||||
|
||||
Waits up to 5 seconds (1 second at a time) for the final file to appear
|
||||
and reach a valid size — covering cameras that are slow to finalise the
|
||||
MP4 after async_record returns.
|
||||
|
||||
If the final file is still not found after 5 attempts, checks for a .tmp
|
||||
version left behind by a failed stream rename and renames it if present
|
||||
(6.7 safety fix — only renames if the final file does NOT already exist).
|
||||
|
||||
In all cases, checks the file size is above _MIN_RECORDING_SIZE to
|
||||
confirm actual video data was written.
|
||||
|
||||
Returns True if a valid recording file is confirmed, False otherwise.
|
||||
"""
|
||||
for attempt in range(5):
|
||||
await asyncio.sleep(1)
|
||||
if os.path.isfile(filename):
|
||||
size = os.path.getsize(filename)
|
||||
if size > _MIN_RECORDING_SIZE:
|
||||
return True
|
||||
|
||||
# Final file not found after 5 attempts — try .tmp fallback.
|
||||
tmp_path = filename + ".tmp"
|
||||
if not os.path.isfile(filename) and os.path.isfile(tmp_path):
|
||||
try:
|
||||
os.rename(tmp_path, filename)
|
||||
except OSError as err:
|
||||
_LOGGER.error(
|
||||
"camera_record_services: failed to rename .tmp file '%s': %s",
|
||||
tmp_path,
|
||||
err,
|
||||
)
|
||||
return False
|
||||
if os.path.isfile(filename):
|
||||
size = os.path.getsize(filename)
|
||||
if size > _MIN_RECORDING_SIZE:
|
||||
return True
|
||||
_LOGGER.warning(
|
||||
"camera_record_services: renamed .tmp file '%s' is too small (%d bytes) "
|
||||
"— no real video data was captured",
|
||||
filename,
|
||||
size,
|
||||
)
|
||||
return False
|
||||
|
||||
if not os.path.isfile(filename) and not os.path.isfile(tmp_path):
|
||||
_LOGGER.warning(
|
||||
"camera_record_services: neither '%s' nor '%s' found after 5 seconds "
|
||||
"— stream object may have failed silently",
|
||||
filename,
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def camera_start_recording(hass, entity_id=None, filename=None, duration=30, lookback=0, stop_state_id=None, stop_state_value=None, extend_running_task=True):
|
||||
"""Start recording a camera stream to a file.
|
||||
|
||||
Replicates camera.record but registers under camera_record_services so it
|
||||
can be extended alongside camera_stop_recording without touching HA core.
|
||||
"""
|
||||
_LOGGER.info("camera_start_recording: called with entity_id='%s' filename='%s' duration=%s lookback=%s stop_state_id='%s' stop_state_value='%s' extend_running_task=%s", entity_id, filename, duration, lookback, stop_state_id, stop_state_value, extend_running_task)
|
||||
if entity_id is None or filename is None:
|
||||
_LOGGER.error("camera_start_recording: 'entity_id' and 'filename' are both required")
|
||||
return
|
||||
|
||||
if (stop_state_id is None) != (stop_state_value is None):
|
||||
_LOGGER.error(
|
||||
"camera_start_recording: 'stop_state_id' and 'stop_state_value' must both be "
|
||||
"provided together or not at all"
|
||||
)
|
||||
return
|
||||
|
||||
camera = _get_camera(hass, entity_id)
|
||||
if camera is None:
|
||||
return
|
||||
|
||||
# async_create_stream returns the existing Stream if already alive,
|
||||
# or creates a new one. It mirrors what camera.__init__ does in
|
||||
# async_handle_record_service.
|
||||
stream = await camera.async_create_stream()
|
||||
if stream is None:
|
||||
_LOGGER.error(
|
||||
"camera_start_recording: could not create stream for '%s' — "
|
||||
"does the camera support streaming?",
|
||||
entity_id,
|
||||
)
|
||||
return
|
||||
|
||||
# If a recording is already in progress, optionally extend it and return early.
|
||||
recorder_output = stream.outputs().get(_RECORDER_PROVIDER)
|
||||
if recorder_output is not None:
|
||||
if extend_running_task:
|
||||
try:
|
||||
# --- OPTIMISTIC CALL ---
|
||||
# Optimistically try to extend the timer.
|
||||
recorder_output.idle_timer.awake()
|
||||
_LOGGER.info(
|
||||
"camera_start_recording: '%s' is already recording to '%s' - this task has been extended. "
|
||||
"New request ignored: filename='%s' duration=%s lookback=%s stop_state_id='%s' stop_state_value='%s'",
|
||||
entity_id, recorder_output.video_path, filename, duration, lookback, stop_state_id, stop_state_value,
|
||||
)
|
||||
except Exception as err:
|
||||
# --- ERROR DIAGNOSIS ---
|
||||
# An error occurred. Let's figure out if it's because the API changed
|
||||
# or if something else went wrong.
|
||||
|
||||
# 1. Check for the 'idle_timer' attribute
|
||||
timer_exists = hasattr(recorder_output, 'idle_timer')
|
||||
|
||||
# 2. Check for the 'awake' method
|
||||
# (Use nested getattr with defaults to ensure this check never crashes)
|
||||
method_exists = callable(
|
||||
getattr(getattr(recorder_output, 'idle_timer', None), 'awake', None)
|
||||
)
|
||||
if not timer_exists or not method_exists:
|
||||
# The internal API has changed/removed.
|
||||
# We include the diagnostic details in the error message.
|
||||
raise HomeAssistantError(
|
||||
f"Cannot extend recording: Internal API mismatch. "
|
||||
f"idle_timer found: {timer_exists}, awake method found: {method_exists}. "
|
||||
f"The Stream component structure has likely changed and this integration needs an update."
|
||||
) from err
|
||||
|
||||
# The API exists, so the error was a genuine runtime error (e.g. inside awake()).
|
||||
# Log the technical details and re-raise the original exception.
|
||||
_LOGGER.error(
|
||||
"camera_start_recording: Failed to extend recording for '%s' due to an internal error: %s",
|
||||
entity_id,
|
||||
err
|
||||
)
|
||||
raise
|
||||
return
|
||||
|
||||
try:
|
||||
_LOGGER.info(
|
||||
"camera_start_recording: starting recording '%s' → %s (duration=%ss, lookback=%ss)",
|
||||
entity_id,
|
||||
filename,
|
||||
duration,
|
||||
lookback,
|
||||
)
|
||||
if stop_state_id is not None:
|
||||
# Run recording as a background task so we can race it against
|
||||
# the stop-state condition.
|
||||
record_task = asyncio.create_task(
|
||||
stream.async_record(
|
||||
video_path=str(filename),
|
||||
duration=int(duration),
|
||||
lookback=int(lookback),
|
||||
)
|
||||
)
|
||||
# Use async_track_state_change_event and an asyncio.Event to watch
|
||||
# for the stop-state condition.
|
||||
async def _stop_state_watcher():
|
||||
event = asyncio.Event()
|
||||
|
||||
@callback
|
||||
def _state_changed(event_obj):
|
||||
new_state = event_obj.data.get("new_state")
|
||||
if new_state and new_state.state == str(stop_state_value):
|
||||
event.set()
|
||||
|
||||
unsub = async_track_state_change_event(hass, [stop_state_id], _state_changed)
|
||||
try:
|
||||
await event.wait()
|
||||
finally:
|
||||
unsub()
|
||||
|
||||
watcher_task = asyncio.create_task(_stop_state_watcher())
|
||||
# Wait for whichever completes first.
|
||||
done, _ = await asyncio.wait(
|
||||
[record_task, watcher_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if record_task not in done:
|
||||
# Stop-state fired before duration expired — stop the recording.
|
||||
_LOGGER.info("camera_start_recording: stop-state fired — cancelling watcher and stopping recording")
|
||||
watcher_task.cancel()
|
||||
recorder_output = stream.outputs().get(_RECORDER_PROVIDER)
|
||||
if recorder_output is not None:
|
||||
await stream.remove_provider(recorder_output)
|
||||
# Wait for record_task to finish cleanly after the stop signal.
|
||||
await record_task
|
||||
else:
|
||||
# Duration expired naturally — cancel the watcher.
|
||||
watcher_task.cancel()
|
||||
else:
|
||||
# No stop condition — await directly.
|
||||
await stream.async_record(
|
||||
video_path=str(filename),
|
||||
duration=int(duration),
|
||||
lookback=int(lookback),
|
||||
)
|
||||
await _check_recording_file(str(filename))
|
||||
_LOGGER.info(
|
||||
"camera_start_recording: recording completed for '%s' → %s",
|
||||
entity_id,
|
||||
filename,
|
||||
)
|
||||
except Exception as err: # HomeAssistantError, OSError, etc.
|
||||
_LOGGER.error(
|
||||
"camera_start_recording: failed to start recording for '%s': %s",
|
||||
entity_id,
|
||||
err,
|
||||
)
|
||||
|
||||
|
||||
async def camera_stop_recording(hass, entity_id=None):
|
||||
"""Stop an in-progress camera recording.
|
||||
|
||||
This is best-effort:
|
||||
- If no stream exists, silently returns.
|
||||
- If no recording is in progress, silently returns.
|
||||
- The actual file will be finalised at the next segment boundary,
|
||||
identical to normal end-of-duration behaviour.
|
||||
- There is no guarantee that anything was recorded.
|
||||
"""
|
||||
_LOGGER.info("camera_stop_recording: called with entity_id='%s'", entity_id)
|
||||
if entity_id is None:
|
||||
_LOGGER.error("camera_stop_recording: 'entity_id' is required")
|
||||
return
|
||||
|
||||
camera = _get_camera(hass, entity_id)
|
||||
if camera is None:
|
||||
return
|
||||
|
||||
# camera.stream is None until the first stream is created.
|
||||
stream = getattr(camera, "stream", None)
|
||||
if stream is None:
|
||||
_LOGGER.debug(
|
||||
"camera_stop_recording: '%s' has no active stream, nothing to stop",
|
||||
entity_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Use the public outputs() method (returns a MappingProxyType copy of
|
||||
# the internal outputs dict) rather than accessing _outputs directly.
|
||||
recorder_output = stream.outputs().get(_RECORDER_PROVIDER)
|
||||
|
||||
if recorder_output is None:
|
||||
_LOGGER.debug(
|
||||
"camera_stop_recording: no recording in progress for '%s'",
|
||||
entity_id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# remove_provider signals the recorder_save_worker thread, drains
|
||||
# its queue, finalises the MP4 container and flushes to disk —
|
||||
# exactly the same teardown path as normal end-of-duration.
|
||||
await stream.remove_provider(recorder_output)
|
||||
await _check_recording_file(recorder_output.video_path)
|
||||
_LOGGER.info(
|
||||
"camera_stop_recording: recording stopped for '%s'", entity_id
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.error(
|
||||
"camera_stop_recording: error while stopping recording for '%s': %s",
|
||||
entity_id,
|
||||
err,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the camera_record_services integration."""
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up camera_record_services from a config entry."""
|
||||
|
||||
async def handle_camera_start_recording(call: ServiceCall) -> None:
|
||||
await camera_start_recording(
|
||||
hass,
|
||||
entity_id=call.data.get("entity_id"),
|
||||
filename=call.data.get("filename"),
|
||||
duration=call.data.get("duration", 30),
|
||||
lookback=call.data.get("lookback", 0),
|
||||
stop_state_id=call.data.get("stop_state_id"),
|
||||
stop_state_value=call.data.get("stop_state_value"),
|
||||
extend_running_task=call.data.get("extend_running_task", True),
|
||||
)
|
||||
|
||||
async def handle_camera_stop_recording(call: ServiceCall) -> None:
|
||||
await camera_stop_recording(
|
||||
hass,
|
||||
entity_id=call.data.get("entity_id"),
|
||||
)
|
||||
|
||||
hass.services.async_register(DOMAIN, "camera_start_recording", handle_camera_start_recording)
|
||||
hass.services.async_register(DOMAIN, "camera_stop_recording", handle_camera_stop_recording)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
hass.services.async_remove(DOMAIN, "camera_start_recording")
|
||||
hass.services.async_remove(DOMAIN, "camera_stop_recording")
|
||||
return True
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,39 @@
|
||||
"""Config flow for camera_record_services."""
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class CameraRecordServicesConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Camera Record Services."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="Camera Record Services", data={})
|
||||
return self.async_show_form(step_id="user")
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Return the options flow handler."""
|
||||
return CameraRecordServicesOptionsFlow()
|
||||
|
||||
|
||||
class CameraRecordServicesOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Show usage guide via the Configure button on the integration page."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialise the options flow."""
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Show the usage guide."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data={})
|
||||
return self.async_show_form(step_id="init")
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Constants for camera_record_services."""
|
||||
|
||||
DOMAIN = "camera_record_services"
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "camera_record_services",
|
||||
"name": "Camera Record Services",
|
||||
"version": "0.9.0",
|
||||
"documentation": "https://github.com/GITHUB_USERNAME/GITHUB_REPO_NAME",
|
||||
"homeassistant": "2024.1.1",
|
||||
"dependencies": ["camera", "stream"],
|
||||
"codeowners": ["@GITHUB_USERNAME"],
|
||||
"requirements": [],
|
||||
"iot_class": "local_push",
|
||||
"config_flow": true
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
camera_start_recording:
|
||||
name: Start recording
|
||||
description: "Record a camera stream to a file. Stops after the set duration, or earlier if a watched entity reaches the specified state."
|
||||
fields:
|
||||
entity_id:
|
||||
name: Camera
|
||||
description: Camera to record.
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: camera
|
||||
filename:
|
||||
name: Filename
|
||||
description: "Full path for the output file, e.g. /media/clips/front.mp4"
|
||||
required: true
|
||||
example: "/media/clip.mp4"
|
||||
selector:
|
||||
text:
|
||||
duration:
|
||||
name: Duration
|
||||
description: Maximum recording length. Recording stops automatically when reached.
|
||||
default: 30
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 3600
|
||||
unit_of_measurement: seconds
|
||||
lookback:
|
||||
name: Lookback
|
||||
description: "Seconds of pre-recorded buffer to include before this call. Only works if the camera stream is already running. Actual lookback may be less than requested depending on the stream buffer."
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 300
|
||||
unit_of_measurement: seconds
|
||||
stop_state_id:
|
||||
name: Stop entity
|
||||
description: "Entity to watch for an early stop. Races against duration — whichever occurs first stops the recording. Must be used with stop_state_value."
|
||||
required: false
|
||||
selector:
|
||||
entity:
|
||||
stop_state_value:
|
||||
name: Stop state value
|
||||
description: "State value that triggers the early stop, e.g. off. Must be used with stop_state_id."
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
extend_running_task:
|
||||
name: Extend if already recording
|
||||
description: "If the camera is already recording, extend the current recording instead of ignoring this request. If false, the request is silently ignored."
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
camera_stop_recording:
|
||||
name: Stop recording
|
||||
description: "Stop an in-progress recording immediately, whether started by this service or by camera.record. Does nothing if the camera is not recording."
|
||||
fields:
|
||||
entity_id:
|
||||
name: Camera
|
||||
description: Camera to stop recording.
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: camera
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Camera Record Services",
|
||||
"description": "Add the Camera Record Services integration. No configuration is required."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Usage Guide",
|
||||
"description": "## camera_record_services.camera_start_recording\n\nStart recording a camera stream to a file.\n\n| Parameter | Required | Default | Description |\n|---|---|---|---|\n| `entity_id` | yes | — | Camera entity to record, e.g. `camera.front_door` |\n| `filename` | yes | — | Full path for the output file, e.g. `/media/clip.mp4` |\n| `duration` | no | `30` | Maximum recording length in seconds |\n| `lookback` | no | `0` | Seconds of stream buffer to include before the call |\n| `stop_state_id` | no | — | Entity to watch for an early-stop trigger |\n| `stop_state_value` | no | — | State value that triggers the early stop |\n| `extend_running_task` | no | `true` | Extend current recording if already in progress |\n\n`stop_state_id` and `stop_state_value` must be provided together or not at all.\n\n---\n\n## camera_record_services.camera_stop_recording\n\nStop an in-progress recording immediately.\n\n| Parameter | Required | Description |\n|---|---|---|\n| `entity_id` | yes | Camera entity whose recording should be stopped |\n\nBest-effort — silently does nothing if no recording is in progress."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Camera Record Services",
|
||||
"description": "Add the Camera Record Services integration. No configuration is required."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Already configured. Only a single configuration is possible."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Usage Guide",
|
||||
"description": "## camera_record_services.camera_start_recording\n\nStart recording a camera stream to a file.\n\n| Parameter | Required | Default | Description |\n|---|---|---|---|\n| `entity_id` | yes | — | Camera entity to record, e.g. `camera.front_door` |\n| `filename` | yes | — | Full path for the output file, e.g. `/media/clip.mp4` |\n| `duration` | no | `30` | Maximum recording length in seconds |\n| `lookback` | no | `0` | Seconds of stream buffer to include before the call |\n| `stop_state_id` | no | — | Entity to watch for an early-stop trigger |\n| `stop_state_value` | no | — | State value that triggers the early stop |\n| `extend_running_task` | no | `true` | Extend current recording if already in progress |\n\n`stop_state_id` and `stop_state_value` must be provided together or not at all.\n\n---\n\n## camera_record_services.camera_stop_recording\n\nStop an in-progress recording immediately.\n\n| Parameter | Required | Description |\n|---|---|---|\n| `entity_id` | yes | Camera entity whose recording should be stopped |\n\nBest-effort — silently does nothing if no recording is in progress."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user