Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 172 additions & 1 deletion reolink_aio/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

import asyncio
import base64
import calendar
import hashlib
import logging
import re
import ssl
import traceback
from datetime import datetime, timedelta, tzinfo
from collections.abc import AsyncIterator
from datetime import date, datetime, timedelta, tzinfo
from io import BytesIO
from math import ceil
from os.path import basename
Expand Down Expand Up @@ -3542,6 +3544,12 @@ async def get_vod_source(
cmd = VodRequestType.DOWNLOAD.value

url = f"{self._url}?cmd={cmd}&source={filename.replace(' ', '%20')}&output=ha_playback_{time_start}.mp4{start_time}"
elif request_type == VodRequestType.RTSP:
# RTSP playback URL – supported on select Reolink cameras/NVRs.
# The credentials are embedded in the URL (same as live RTSP streams).
safe_filename = filename.replace(" ", "%20")
rtsp_url = f"rtsp://{self._username}:{self._enc_password}@{self._host}:{self._rtsp_port}/vod/{safe_filename}"
return ("video/mp4", rtsp_url)
else:
raise InvalidParameterError(f"get_vod_source: unsupported request_type '{request_type.value}'")

Expand Down Expand Up @@ -5356,6 +5364,10 @@ async def set_audio_alarm(self, channel: int, enable: bool) -> None:

await self.send_setting(body)

# -------------------------------------------------------------------------
# Two-way audio (talk) public API
# -------------------------------------------------------------------------

async def set_siren(self, channel: int | None = None, enable: bool = True, duration: int | None = 2) -> None:
if channel not in self._channels and channel is not None:
raise InvalidParameterError(f"set_siren: no camera connected to channel '{channel}'")
Expand Down Expand Up @@ -5793,6 +5805,165 @@ async def request_vod_files(

return statuses, vod_files

async def get_recording_days(self, channel: int, year: int, month: int) -> set[int]:
"""Return the set of day-numbers (1–31) in *year*/*month* that have recordings.

Convenience wrapper around request_vod_files(status_only=True).
Useful for populating a calendar view in Home Assistant's media browser.

Example::

days = await host.get_recording_days(0, 2024, 6)
# {1, 3, 14, 15, 28} → recordings exist on those days
"""
if channel not in self._stream_channels:
raise InvalidParameterError(f"get_recording_days: no camera connected to channel '{channel}'")

if self.baichuan_only:
return await self.baichuan.search_recording_days_bc(channel, year, month)

last_day = calendar.monthrange(year, month)[1]
start = datetime(year, month, 1, 0, 0, 0)
end = datetime(year, month, last_day, 23, 59, 59)

statuses, _ = await self.request_vod_files(channel, start, end, status_only=True)

days: set[int] = set()
for status in statuses:
if status.year == year and status.month == month:
days.update(status.days)
return days

async def get_recordings_for_day(
self,
channel: int,
day: date,
stream: Optional[str] = None,
trigger: typings.VOD_trigger | None = None,
) -> list[typings.VOD_file]:
"""Return recordings for *channel* on the given *day*, sorted by start time.

Recordings are deduplicated so that the same file only appears once
even when it matches multiple detection triggers.

Parameters
----------
channel:
Camera channel index.
day:
The calendar date to query (e.g. ``date(2024, 6, 14)``).
stream:
Stream type (``"main"``, ``"sub"``, …). Defaults to the host default.
trigger:
Optional filter. When given only recordings matching that
``VOD_trigger`` flag are returned.

Returns
-------
list[VOD_file]
Sorted (by start_time), deduplicated list. Each item exposes:

* ``file.start_time`` / ``file.end_time`` – datetime with tz
* ``file.duration`` – timedelta
* ``file.triggers`` – VOD_trigger flags (motion, person, …)
* ``file.file_name`` – filename for use with get_vod_source()
* ``file.size`` – file size in bytes

Obtain a playback URL with::

mime, url = await host.get_vod_source(channel, file.file_name)
"""
if channel not in self._stream_channels:
raise InvalidParameterError(f"get_recordings_for_day: no camera connected to channel '{channel}'")

if self.baichuan_only:
vod_files = await self.baichuan.search_recordings_for_day_bc(channel, day, stream)
if trigger is not None:
vod_files = [f for f in vod_files if f.bc_triggers is not None and bool(f.bc_triggers & trigger)]
vod_files.sort(key=lambda f: f.start_time)
return vod_files

start = datetime(day.year, day.month, day.day, 0, 0, 0)
end = datetime(day.year, day.month, day.day, 23, 59, 59)

_, vod_files = await self.request_vod_files(channel, start, end, status_only=False, stream=stream, trigger=trigger)

# Deduplicate by file_name then sort chronologically
seen: set[str] = set()
unique: list[typings.VOD_file] = []
for f in vod_files:
key = f.file_name
if key not in seen:
seen.add(key)
unique.append(f)

unique.sort(key=lambda f: f.start_time)
return unique

def stream_recording_bc(
self,
channel: int,
file_name: str,
start_time: datetime,
stream_type: str = "mainStream",
) -> AsyncIterator[tuple[int, bytes, str]]:
"""Async generator: stream a VOD recording via the Baichuan protocol.

Yields ``(microseconds, video_bytes, codec)`` tuples for each video frame.
``microseconds`` is the camera-relative timestamp (u32, wraps at ~71 min).
``video_bytes`` is the raw video NAL data for the frame.
``codec`` is ``"H264"`` or ``"H265"`` — detected from the BcMedia header and
overridden by NAL-level analysis when a firmware bug causes mislabelling.

Use this for baichuan_only cameras where HTTP download is unavailable.

Parameters
----------
channel:
Camera channel index.
file_name:
Recording filename from ``get_recordings_for_day()``.
start_time:
Recording start time (from ``VOD_file.start_time``).
stream_type:
``"mainStream"`` (default) or ``"subStream"``.

Usage::

async for microseconds, video_bytes, codec in host.stream_recording_bc(ch, name, start_time):
...
"""
return self.baichuan.parse_bcmedia_frames(self.baichuan.stream_replay_bc(channel, file_name, start_time, stream_type))

def stream_live_bc(
self,
channel: int,
stream_type: str = "mainStream",
) -> AsyncIterator[tuple[int, bytes, str]]:
"""Async generator: stream live video via the Baichuan protocol.

Yields ``(microseconds, video_bytes, codec)`` tuples for each video frame.
``microseconds`` is the camera-relative timestamp (u32, wraps at ~71 min).
``video_bytes`` is the raw video NAL data.
``codec`` is ``"H264"`` or ``"H265"``.

Break out of the loop to stop the stream — the PreviewStop command is sent
automatically on generator close.

Parameters
----------
channel:
Camera channel index.
stream_type:
``"mainStream"`` (default) or ``"subStream"``.

Usage::

async for microseconds, video_bytes, codec in host.stream_live_bc(ch):
...
"""
return self.baichuan.parse_bcmedia_frames(self.baichuan.stream_live_bc(channel, stream_type))

async def send_setting(self, body: typings.reolink_json, wait_before_get: int = 0, getcmd: str = "") -> None:
command = body[0]["cmd"]
_LOGGER.debug(
Expand Down
Loading