Initial upload

This commit is contained in:
vahagit
2026-03-14 13:08:07 +00:00
committed by GitHub
parent c43c63cbbb
commit d249c78cf1
9 changed files with 543 additions and 0 deletions
@@ -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."
}
}
}
}