Files
frigate/frigate/util/services.py
T
Josh HawkinsandGitHub 3d08bbe520
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Miscellaneous fixes (#24402)
* check for a valid frame before using its shape

With the camera offline, no preview frame, and `camera-error.jpg` missing, `latest_frame` read `frame.shape` before its `frame is None` check, so it raised `AttributeError` and answered 500 with a traceback instead of the intended "Unable to get valid frame". The check now runs first.

* fix the has_clip self-heal for events with no recordings

`vod_event` looked for a `(body, 404)` tuple, but `vod_ts` returns a `JSONResponse`, so the check never matched and an old event whose recordings are gone kept offering a clip that can't play. It now checks the response status code.

* return 403 for a snapshot or thumbnail on another camera

The broad `except Exception` handlers in `event_snapshot` and `event_thumbnail` caught the `HTTPException` from `require_camera_access`, so a restricted user asking for another camera's snapshot got a 404 instead of a 403, and for an object still being tracked the snapshot was rendered before the check ran. Both endpoints now look up the event and check access in their own block, the way the other endpoints do, so a denial propagates.

* find DST transitions to the second

`get_dst_transitions` probed the offset once every 24 hours from the start time and reported a change at the first probe after it, up to a day late, so events, review items and recordings near a transition were grouped into days with the old offset. A transition after the last daily probe wasn't found at all. The end of the range is probed too now, and a probe that sees the offset change bisects the interval to the second of the transition.

* don't run page shortcuts for keys a dialog already handled

Radix dismisses a dialog on Escape from a capture-phase keydown listener and calls `preventDefault()` without stopping propagation, so `useKeyboardListener` still ran the page's Escape shortcut: cancelling the delete dialog in the face library or a classification model also cleared the whole selection. Keys another shortcut hook handled still get through, since their listener order changes with every render.

* fix train image filtering for a class with a dash

The backend writes a class with a `-` as `_` in train file names, since it splits those names on `-`, while a dataset folder keeps the dash. Filtering the Train grid by `half-open` compared it with `half_open` and hid every attempt. Both sides are normalized the same way now.

* don't edit a chat message while a reply streams

The edit button stayed active while a reply streamed. `submitConversation` returns early while loading, but the message bubble still closed its editor, so the edit was silently lost. The edit button is hidden while a reply streams, and an editor that's already open keeps its draft with send disabled until the reply ends.

* fix restart failing under non-root

restart_frigate() called psutil.Process(1).terminate() to signal s6-svscan, but s6-svscan runs as root while frigate runs as uid 1000, so the call raised AccessDenied. That exception escaped every caller: the UI restart button dropped its websocket client, MQTT restart and Save & Restart just logged and did nothing, and the watchdog crashed its own monitoring thread on a dead detector. This catches AccessDenied and falls through to the existing SIGINT branch, which exits the process for s6 to restart it.

* show runtime overrides in the settings form

The settings form read a camera section's saved config value, but its dependent warnings (audio transcription requiring audio detection, snapshots requiring detect, etc.) read the live config instead. A runtime toggle from the live view, MQTT, or an active profile can turn a section off without touching yaml, and that override persists across restarts, so the Enable switch showed on while the warning said the feature wasn't enabled. This adds an "Overridden (Live)" badge to any field whose live value differs from what's saved, and swaps the affected warnings to runtime-specific wording when a runtime override is the actual cause instead of the config.

* fix mobile overflowing icons in system due to new health pane

* fix genai settings keeping a stale model and dropping roles after save

Switching a GenAI entry's provider left the previous provider's model selected, so saving wrote a model the new provider doesn't serve. llama.cpp can't find that model in `/v1/models`, so the backend reported every capability as false for the entry, and once the save refetched `genai/models` the roles widget stripped `transcribe` from the form on its own. The section showed unsaved changes right after saving, and saving again would have dropped the role. Switching provider now clears the model, and the roles widget only strips a role for a model or provider picked in the form, since the entry-level capability flags only describe the saved model. A selected role stays visible when the provider can't confirm it, so it can still be switched off. The llama.cpp model list also no longer repeats a model whose alias matches its id, which is what `--alias` produces.

* close onvif sessions on shutdown

`OnvifController.close()` only stopped its event loop, so the aiohttp sessions each `ONVIFCamera` holds and the `_poll_config_updates` task were left to be garbage collected during interpreter shutdown, when their warnings can no longer be logged. Every restart ended with a run of `Unclosed client session` and `Task was destroyed but it is pending!` logging errors, which only became visible once restart started exiting the process itself under non-root. `close()` now closes each camera's client and cancels the tasks on the loop before stopping it.

* fixes

* fixes
2026-09-18 07:33:23 -06:00

1697 lines
55 KiB
Python

"""Utilities for services."""
import asyncio
import contextlib
import glob
import json
import logging
import os
import re
import resource
import shutil
import signal
import subprocess as sp
import time
import traceback
from datetime import datetime
from typing import Any
import cv2
import psutil
import py3nvml.py3nvml as nvml
import requests
from frigate.const import (
DRIVER_AMD,
DRIVER_ENV_VAR,
FFMPEG_HWACCEL_NVIDIA,
FFMPEG_HWACCEL_VAAPI,
SHM_FRAMES_VAR,
)
from frigate.util.builtin import clean_camera_user_pass, escape_special_characters
logger = logging.getLogger(__name__)
def restart_frigate():
proc = psutil.Process(1)
# if this is running via s6, sigterm pid 1
if proc.name() == "s6-svscan":
try:
proc.terminate()
return
except psutil.AccessDenied:
# frigate runs unprivileged, so it cannot signal root's s6-svscan.
# exiting this process instead runs frigate/finish, which halts s6
logger.debug("Not permitted to signal s6-svscan, exiting instead")
# otherwise, just try and exit frigate
os.kill(os.getpid(), signal.SIGINT)
def print_stack(sig, frame):
traceback.print_stack(frame)
def listen():
signal.signal(signal.SIGUSR1, print_stack)
def get_cgroups_version() -> str:
"""Determine what version of cgroups is enabled."""
cgroup_path = "/sys/fs/cgroup"
if not os.path.ismount(cgroup_path):
logger.debug(f"{cgroup_path} is not a mount point.")
return "unknown"
try:
with open("/proc/mounts") as f:
mounts = f.readlines()
for mount in mounts:
mount_info = mount.split()
if mount_info[1] == cgroup_path:
fs_type = mount_info[2]
if fs_type == "cgroup2fs" or fs_type == "cgroup2":
return "cgroup2"
elif fs_type == "tmpfs":
return "cgroup"
else:
logger.debug(
f"Could not determine cgroups version: unhandled filesystem {fs_type}"
)
break
except Exception as e:
logger.debug(f"Could not determine cgroups version: {e}")
return "unknown"
def get_docker_memlimit_bytes() -> int:
"""Get mem limit in bytes set in docker if present. Returns -1 if no limit detected."""
# check running a supported cgroups version
if get_cgroups_version() == "cgroup2":
memlimit_path = "/sys/fs/cgroup/memory.max"
try:
with open(memlimit_path) as f:
value = f.read().strip()
if value.isnumeric():
return int(value)
elif value.lower() == "max":
return -1
except Exception as e:
logger.debug(f"Unable to get docker memlimit: {e}")
return -1
def get_cpu_stats() -> dict[str, dict]:
"""Get cpu usages for each process id"""
usages = {}
docker_memlimit = get_docker_memlimit_bytes() / 1024
total_mem = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 1024
system_cpu = psutil.cpu_percent(
interval=None
) # no interval as we don't want to be blocking
system_mem = psutil.virtual_memory()
usages["frigate.full_system"] = {
"cpu": str(system_cpu),
"mem": str(system_mem.percent),
}
keywords = ["ffmpeg", "go2rtc", "frigate.", "python3"]
for process in psutil.process_iter(["pid", "name", "cpu_percent", "cmdline"]):
pid = str(process.info["pid"])
try:
cpu_percent = process.info["cpu_percent"]
cmdline = " ".join(process.info["cmdline"]).rstrip()
if not any(keyword in cmdline for keyword in keywords):
continue
with open(f"/proc/{pid}/stat") as f:
stats = f.readline().split()
utime = int(stats[13])
stime = int(stats[14])
start_time = int(stats[21])
with open("/proc/uptime") as f:
system_uptime_sec = int(float(f.read().split()[0]))
clk_tck = os.sysconf(os.sysconf_names["SC_CLK_TCK"])
process_utime_sec = utime // clk_tck
process_stime_sec = stime // clk_tck
process_start_time_sec = start_time // clk_tck
process_elapsed_sec = system_uptime_sec - process_start_time_sec
process_usage_sec = process_utime_sec + process_stime_sec
cpu_average_usage = process_usage_sec * 100 // process_elapsed_sec
with open(f"/proc/{pid}/statm") as f:
mem_stats = f.readline().split()
mem_res = int(mem_stats[1]) * os.sysconf("SC_PAGE_SIZE") / 1024
if docker_memlimit > 0:
mem_pct = round((mem_res / docker_memlimit) * 100, 1)
else:
mem_pct = round((mem_res / total_mem) * 100, 1)
usages[pid] = {
"cpu": str(cpu_percent),
"cpu_average": str(round(cpu_average_usage, 2)),
"mem": f"{mem_pct}",
"cmdline": clean_camera_user_pass(cmdline),
}
except Exception:
continue
return usages
def get_physical_interfaces(interfaces) -> list:
if not interfaces:
return []
with open("/proc/net/dev") as file:
lines = file.readlines()
physical_interfaces = []
for line in lines:
if ":" in line:
interface = line.split(":")[0].strip()
for int in interfaces:
if interface.startswith(int):
physical_interfaces.append(interface)
return physical_interfaces
_bandwidth_warning_logged = False
def get_bandwidth_stats(config) -> dict[str, dict]:
"""Get bandwidth usages for each ffmpeg process id"""
global _bandwidth_warning_logged
if os.geteuid() != 0:
if not _bandwidth_warning_logged:
logger.warning(
"Network bandwidth stats require root (nethogs needs CAP_NET_ADMIN/CAP_NET_RAW) "
"and are disabled; set FRIGATE_ROOT_SERVICES=frigate (or FRIGATE_RUN_AS_ROOT=true) "
"or disable telemetry.stats.network_bandwidth to silence this warning"
)
_bandwidth_warning_logged = True
return {}
usages = {}
top_command = ["nethogs", "-t", "-v0", "-c5", "-d1"] + get_physical_interfaces(
config.telemetry.network_interfaces
)
p = sp.run(
top_command,
encoding="ascii",
capture_output=True,
)
if p.returncode != 0:
logger.error(f"Error getting network stats :: {p.stderr}")
return usages
else:
lines = p.stdout.split("\n")
for line in lines:
stats = list(filter(lambda a: a != "", line.strip().split("\t")))
try:
if re.search(
r"(^ffmpeg|\/go2rtc|frigate\.detector\.[a-z]+)/([0-9]+)/", stats[0]
):
process = stats[0].split("/")
usages[process[len(process) - 2]] = {
"bandwidth": round(float(stats[1]) + float(stats[2]), 1),
}
except (IndexError, ValueError):
continue
return usages
def is_vaapi_amd_driver() -> bool:
# Use the explicitly configured driver, if available
driver = os.environ.get(DRIVER_ENV_VAR)
if driver:
return driver == DRIVER_AMD
# Otherwise, ask vainfo what is has autodetected
p = vainfo_hwaccel()
if p.returncode != 0:
logger.error(f"Unable to poll vainfo: {p.stderr}")
return False
else:
output = p.stdout.decode("unicode_escape").split("\n")
# VA Info will print out the friendly name of the driver
return any("AMD Radeon Graphics" in line for line in output)
def get_amd_gpu_stats() -> dict[str, str] | None:
"""Get stats using radeontop."""
radeontop_command = ["radeontop", "-d", "-", "-l", "1"]
p = sp.run(
radeontop_command,
encoding="ascii",
capture_output=True,
)
if p.returncode != 0:
logger.error(f"Unable to poll radeon GPU stats: {p.stderr}")
return None
else:
usages = p.stdout.split(",")
results: dict[str, str] = {}
for hw in usages:
if "gpu" in hw:
results["gpu"] = f"{hw.strip().split(' ')[1].replace('%', '')}%"
elif "vram" in hw:
results["mem"] = f"{hw.strip().split(' ')[1].replace('%', '')}%"
return results
_INTEL_FDINFO_SAMPLE_SECONDS = 1.0
# Engines we track. Render/3D and Compute are pooled into "compute"; Video and
# VideoEnhance into "dec" (VideoEnhance is the post-process engine that handles
# VAAPI scaling/deinterlace/CSC, e.g. ffmpeg `-vf scale_vaapi=...`). The Copy
# (DMA blitter) engine is intentionally ignored — it represents transparent
# memory transfers, not user-visible GPU work.
# i915 fdinfo keys (cumulative ns) → logical engine name.
_I915_ENGINE_KEYS = {
"drm-engine-render": "render",
"drm-engine-video": "video",
"drm-engine-video-enhance": "video-enhance",
"drm-engine-compute": "compute",
}
# Xe fdinfo suffixes (cumulative cycles, paired with drm-total-cycles-*).
_XE_ENGINE_KEYS = {
"rcs": "render",
"vcs": "video",
"vecs": "video-enhance",
"ccs": "compute",
}
_INTEL_DRM_DRIVERS = ("i915", "xe")
_PCI_ADDRESS_RE = re.compile(
r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$"
)
def _resolve_intel_gpu_pdev(device: str | None) -> str | None:
"""Map a configured GPU hint (/dev/dri/card1, renderD128, or a PCI bus
address) to its drm-pdev string so we can filter fdinfo entries to that
device. Returns None when no hint is supplied or it cannot be resolved."""
if not device:
return None
if _PCI_ADDRESS_RE.match(device):
return device
name = os.path.basename(device.rstrip("/"))
try:
pdev = os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device"))
except OSError:
return None
# realpath does not raise on a nonexistent node; it returns the input
# path unchanged, so validate the result actually looks like a PCI
# address before trusting it.
return pdev if _PCI_ADDRESS_RE.match(pdev) else None
def enumerate_drm_devices() -> dict[str, str]:
"""Map each PCI-attached DRM device to its bound kernel driver.
Reads /sys/class/drm, which reflects every GPU on the host even when only
some render nodes are mapped into the container, so device presence can be
verified without /dev access. Returns {pdev: driver}, e.g.
{"0000:00:02.0": "i915"}.
"""
devices: dict[str, str] = {}
try:
entries = os.listdir("/sys/class/drm")
except OSError:
return devices
for entry in entries:
device_dir = f"/sys/class/drm/{entry}/device"
pdev = os.path.basename(os.path.realpath(device_dir))
if not _PCI_ADDRESS_RE.match(pdev):
continue
try:
driver = os.path.basename(os.readlink(f"{device_dir}/driver"))
except OSError:
continue
devices[pdev] = driver
return devices
def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict | None:
"""Snapshot DRM fdinfo for every Intel client visible in /proc.
Returns a dict keyed by (pdev, drm-client-id, pid) so the same context
seen via multiple file descriptors on a single process collapses to one
entry. Clients whose fdinfo carries no engine counters are still included
with an empty "engines" dict so the caller can distinguish "clients exist
but the kernel publishes no busyness" from "no clients at all". Returns
None when /proc itself cannot be scanned, which is a different failure
than a scan that finds nothing.
"""
snapshot: dict = {}
try:
proc_entries = os.listdir("/proc")
except OSError:
return None
for entry in proc_entries:
if not entry.isdigit():
continue
fdinfo_dir = f"/proc/{entry}/fdinfo"
try:
fds = os.listdir(fdinfo_dir)
except (FileNotFoundError, PermissionError, NotADirectoryError, OSError):
continue
for fd in fds:
try:
with open(f"{fdinfo_dir}/{fd}") as f:
content = f.read()
except (FileNotFoundError, PermissionError, OSError):
continue
if "drm-driver" not in content:
continue
fields: dict[str, str] = {}
for line in content.splitlines():
key, sep, value = line.partition(":")
if sep:
fields[key.strip()] = value.strip()
driver = fields.get("drm-driver")
if driver not in ("i915", "xe"):
continue
pdev = fields.get("drm-pdev", "")
if target_pdev and pdev != target_pdev:
continue
client_id = fields.get("drm-client-id")
if not client_id:
continue
key = (pdev, client_id, entry)
if key in snapshot:
continue
engines: dict[str, tuple[int, int, int]] = {}
if driver == "i915":
for fkey, engine in _I915_ENGINE_KEYS.items():
raw = fields.get(fkey)
if not raw:
continue
try:
engines[engine] = (int(raw.split()[0]), 0, 1)
except (ValueError, IndexError):
continue
else:
for suffix, engine in _XE_ENGINE_KEYS.items():
busy_raw = fields.get(f"drm-cycles-{suffix}")
total_raw = fields.get(f"drm-total-cycles-{suffix}")
if not (busy_raw and total_raw):
continue
# drm-cycles-* is summed across every instance of the engine
# class while drm-total-cycles-* tracks a single instance, so
# busy/total scales up to the capacity (e.g. Battlemage
# reports 2 for vcs/vecs). Capture it to divide back out;
# absent means a single engine, so default to 1.
capacity_raw = fields.get(f"drm-engine-capacity-{suffix}")
try:
capacity = int(capacity_raw.split()[0]) if capacity_raw else 1
except (ValueError, IndexError):
capacity = 1
try:
engines[engine] = (
int(busy_raw.split()[0]),
int(total_raw.split()[0]),
max(1, capacity),
)
except (ValueError, IndexError):
continue
snapshot[key] = {"driver": driver, "pid": entry, "engines": engines}
return snapshot
def _idle_intel_gpu_stats(
target_pdev: str | None, intel_pdevs: dict[str, str]
) -> dict[str, dict[str, Any]]:
"""Build a 0% reading for the configured (or every) Intel GPU.
Used when the device is confirmed present but no DRM client is currently
attached, e.g. while camera processes are restarting. That is an idle
state, not a collection failure, so it must produce a valid reading:
returning None would latch the hwaccel error cooldown and blank GPU stats
for an hour over a momentary gap.
"""
from frigate.stats.intel_gpu_info import intel_gpu_name_resolver
names = intel_gpu_name_resolver.get_names()
pdevs = [target_pdev] if target_pdev else sorted(intel_pdevs)
return {
pdev: {
"name": names.get(pdev) or "Intel iGPU",
"vendor": "intel",
"gpu": "0.0%",
"mem": "-%",
"compute": "0.0%",
"dec": "0.0%",
}
for pdev in pdevs
}
def get_intel_gpu_stats(
intel_gpu_device: str | None,
) -> dict[str, dict[str, Any]] | None:
"""Get stats by reading DRM fdinfo files, bucketed per-pdev.
Each DRM client FD exposes monotonic per-engine busy counters via
/proc/<pid>/fdinfo/<fd>. For i915 this requires kernel 6.5 or newer:
earlier kernels omit the per-engine counters whenever GuC submission is
active, which is the default on 12th gen and newer. Xe has exposed them
since its first release. We sample twice and divide busy-time deltas by
wall-clock to derive utilization. Render/3D and Compute are pooled into
"compute"; Video and VideoEnhance into "dec". Overall "gpu" is the sum of
those pools (clamped to 100%).
The return value is keyed by the GPU's drm-pdev string so multiple Intel
GPUs in the same system are reported separately. Each entry carries a
"name" populated from OpenVINO (falling back to the pdev) so callers can
surface a real device name in the UI.
A device that exists but has no attached DRM clients reports an idle 0%
reading. None is returned only for durable failures (no Intel GPU, a bad
intel_gpu_device config, unreadable /proc, or a kernel that publishes no
counters), each of which logs a distinct warning, and the caller latches
it against retries for an hour.
"""
from frigate.stats.intel_gpu_info import intel_gpu_name_resolver
target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device)
if intel_gpu_device and not target_pdev:
logger.warning(
"Unable to collect Intel GPU stats: configured intel_gpu_device %s "
"does not exist or could not be resolved to a PCI device",
intel_gpu_device,
)
return None
drm_devices = enumerate_drm_devices()
intel_pdevs = {
pdev: driver
for pdev, driver in drm_devices.items()
if driver in _INTEL_DRM_DRIVERS
}
if not intel_pdevs:
logger.warning(
"Unable to collect Intel GPU stats: no Intel GPU (i915/xe) found in "
"/sys/class/drm. Check that the driver is loaded on the host"
)
return None
if target_pdev and target_pdev not in intel_pdevs:
logger.warning(
"Unable to collect Intel GPU stats: configured intel_gpu_device %s "
"resolved to %s (driver: %s), which is not an Intel GPU",
intel_gpu_device,
target_pdev,
drm_devices.get(target_pdev, "unknown"),
)
return None
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
if snapshot_a is None:
logger.warning("Unable to collect Intel GPU stats: /proc could not be read")
return None
if not snapshot_a:
# No process currently holds the GPU open, e.g. while camera processes
# are restarting. The device is confirmed present, so report idle
# rather than an error; the next stats cycle re-samples normally.
logger.debug("No active DRM clients for Intel GPU, reporting idle")
return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
if not any(client["engines"] for client in snapshot_a.values()):
# Clients exist but the kernel published no busyness for them, so
# there is nothing to sample and a second snapshot would not help.
# i915 suppresses per-client engine counters while GuC submission is
# active on kernels older than 6.5 (kernel commit 1324680a80eb lifted
# this), which covers stock Debian 12 and Ubuntu 22.04 on 12th gen
# and newer.
logger.warning(
"Unable to collect Intel GPU stats: found %d DRM client(s) for %s but "
"no per-engine counters. Kernel 6.5 or newer is required.",
len(snapshot_a),
"/".join(sorted({client["driver"] for client in snapshot_a.values()})),
)
return None
start = time.monotonic()
time.sleep(_INTEL_FDINFO_SAMPLE_SECONDS)
elapsed_ns = (time.monotonic() - start) * 1e9
snapshot_b = _read_intel_drm_fdinfo(target_pdev)
if snapshot_b is None:
logger.warning("Unable to collect Intel GPU stats: /proc could not be read")
return None
if not snapshot_b or elapsed_ns <= 0:
# Every client disappeared during the sample window; transient by
# definition, so report idle instead of latching an error.
logger.debug(
"No DRM clients persisted across Intel GPU samples, reporting idle"
)
return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
def _new_engine_pct() -> dict[str, float]:
return {"render": 0.0, "video": 0.0, "video-enhance": 0.0, "compute": 0.0}
per_pdev_engine_pct: dict[str, dict[str, float]] = {}
per_pdev_pid_pct: dict[str, dict[str, float]] = {}
for key, data_b in snapshot_b.items():
data_a = snapshot_a.get(key)
if not data_a or data_a["driver"] != data_b["driver"]:
continue
# Skip before the setdefault below so a counter-less client cannot
# register its pdev on its own.
if not data_b["engines"]:
continue
pdev = key[0]
engine_pct = per_pdev_engine_pct.setdefault(pdev, _new_engine_pct())
pid_pct = per_pdev_pid_pct.setdefault(pdev, {})
client_total = 0.0
for engine, (busy_b, total_b, capacity) in data_b["engines"].items():
if engine not in engine_pct:
continue
busy_a, total_a, _ = data_a["engines"].get(
engine, (busy_b, total_b, capacity)
)
if data_b["driver"] == "i915":
delta = max(0, busy_b - busy_a)
pct = min(100.0, delta / elapsed_ns * 100.0)
else:
delta_busy = max(0, busy_b - busy_a)
delta_total = total_b - total_a
if delta_total <= 0:
continue
# Normalize by capacity so a class with N engine instances
# (busy summed across all N) reports 0-100%, not 0-N*100%.
pct = min(100.0, delta_busy / (delta_total * capacity) * 100.0)
engine_pct[engine] += pct
client_total += pct
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
if not per_pdev_engine_pct:
# Clients were seen in both snapshots but none persisted as the same
# (pdev, client-id, pid); process churn, so report idle.
logger.debug(
"No DRM clients persisted across Intel GPU samples, reporting idle"
)
return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
names = intel_gpu_name_resolver.get_names()
results: dict[str, dict[str, Any]] = {}
for pdev, engine_pct in per_pdev_engine_pct.items():
for engine in engine_pct:
engine_pct[engine] = min(100.0, engine_pct[engine])
compute_pct = min(100.0, engine_pct["render"] + engine_pct["compute"])
dec_pct = min(100.0, engine_pct["video"] + engine_pct["video-enhance"])
overall_pct = min(100.0, compute_pct + dec_pct)
entry: dict[str, Any] = {
"name": names.get(pdev) or "Intel iGPU",
"vendor": "intel",
"gpu": f"{round(overall_pct, 2)}%",
"mem": "-%",
"compute": f"{round(compute_pct, 2)}%",
"dec": f"{round(dec_pct, 2)}%",
}
pid_pct = per_pdev_pid_pct.get(pdev)
if pid_pct:
entry["clients"] = {
pid: f"{round(min(100.0, pct), 2)}%" for pid, pct in pid_pct.items()
}
results[pdev] = entry
return results
def get_openvino_npu_stats() -> dict[str, str] | None:
"""Get NPU stats using openvino."""
for accel_path in sorted(glob.glob("/sys/class/accel/accel*")):
try:
driver = os.path.basename(os.readlink(f"{accel_path}/device/driver"))
except OSError:
continue
if driver != "intel_vpu":
continue
try:
runtime_path = f"{accel_path}/device/power/runtime_active_time"
with open(runtime_path) as f:
initial_runtime = float(f.read().strip())
break
except (FileNotFoundError, PermissionError, ValueError):
continue
else:
return None
try:
initial_time = time.time()
# Sleep for 1 second to get an accurate reading
time.sleep(1.0)
# Read runtime value again
with open(runtime_path) as f:
current_runtime = float(f.read().strip())
current_time = time.time()
# Calculate usage percentage
runtime_diff = current_runtime - initial_runtime
time_diff = (current_time - initial_time) * 1000.0 # Convert to milliseconds
if time_diff > 0:
usage = min(100.0, max(0.0, (runtime_diff / time_diff * 100.0)))
else:
usage = 0.0
return {"npu": f"{round(usage, 2)}", "mem": "-%"}
except (FileNotFoundError, PermissionError, ValueError):
return None
def get_rockchip_gpu_stats() -> dict[str, str | float] | None:
"""Get GPU stats using rk."""
try:
with open("/sys/kernel/debug/rkrga/load") as f:
content = f.read()
except FileNotFoundError:
return None
load_values = []
for line in content.splitlines():
match = re.search(r"load = (\d+)%", line)
if match:
load_values.append(int(match.group(1)))
if not load_values:
return None
average_load = f"{round(sum(load_values) / len(load_values), 2)}%"
stats: dict[str, str | float] = {"gpu": average_load, "mem": "-%"}
try:
with open("/sys/class/thermal/thermal_zone5/temp") as f:
line = f.readline().strip()
stats["temp"] = round(int(line) / 1000, 1)
except (FileNotFoundError, OSError, ValueError):
pass
return stats
def get_rockchip_npu_stats() -> dict[str, float | str] | None:
"""Get NPU stats using rk."""
try:
with open("/sys/kernel/debug/rknpu/load") as f:
npu_output = f.read()
if "Core0:" in npu_output:
# multi core NPU
core_loads = re.findall(r"Core\d+:\s*(\d+)%", npu_output)
else:
# single core NPU
core_loads = re.findall(r"NPU load:\s+(\d+)%", npu_output)
except FileNotFoundError:
core_loads = None
if not core_loads:
return None
percentages = [int(load) for load in core_loads]
mean = round(sum(percentages) / len(percentages), 2)
stats: dict[str, float | str] = {"npu": mean, "mem": "-%"}
try:
with open("/sys/class/thermal/thermal_zone6/temp") as f:
line = f.readline().strip()
stats["temp"] = round(int(line) / 1000, 1)
except (FileNotFoundError, OSError, ValueError):
pass
return stats
def get_axcl_npu_stats() -> dict[str, str | float] | None:
"""Get NPU stats using axcl."""
# Check if axcl-smi exists
axcl_smi_path = "/usr/bin/axcl/axcl-smi"
if not os.path.exists(axcl_smi_path):
return None
try:
# Run axcl-smi command to get NPU stats
axcl_command = [axcl_smi_path, "sh", "cat", "/proc/ax_proc/npu/top"]
p = sp.run(
axcl_command,
capture_output=True,
text=True,
)
if p.returncode != 0:
pass
else:
utilization = None
for line in p.stdout.strip().splitlines():
line = line.strip()
if line.startswith("utilization:"):
match = re.search(r"utilization:(\d+)%", line)
if match:
utilization = float(match.group(1))
if utilization is not None:
stats: dict[str, str | float] = {"npu": utilization, "mem": "-%"}
return stats
except Exception:
pass
return None
def try_get_info(f, h, default="N/A", sensor=None):
try:
if h:
if sensor is not None:
v = f(h, sensor)
else:
v = f(h)
else:
v = f()
except nvml.NVMLError_NotSupported:
v = default
return v
def get_nvidia_gpu_stats() -> dict[int, dict]:
names: dict[str, int] = {}
results = {}
try:
nvml.nvmlInit()
deviceCount = nvml.nvmlDeviceGetCount()
for i in range(deviceCount):
handle = nvml.nvmlDeviceGetHandleByIndex(i)
gpu_name = nvml.nvmlDeviceGetName(handle)
# handle case where user has multiple of same GPU
if gpu_name in names:
names[gpu_name] += 1
gpu_name += f" ({names.get(gpu_name)})"
else:
names[gpu_name] = 1
meminfo = try_get_info(nvml.nvmlDeviceGetMemoryInfo, handle)
util = try_get_info(nvml.nvmlDeviceGetUtilizationRates, handle)
enc = try_get_info(nvml.nvmlDeviceGetEncoderUtilization, handle)
dec = try_get_info(nvml.nvmlDeviceGetDecoderUtilization, handle)
temp = try_get_info(
nvml.nvmlDeviceGetTemperature, handle, default=None, sensor=0
)
pstate = try_get_info(nvml.nvmlDeviceGetPowerState, handle, default=None)
if util != "N/A":
gpu_util = util.gpu
else:
gpu_util = 0
if meminfo != "N/A":
gpu_mem_util = meminfo.used / meminfo.total * 100
else:
gpu_mem_util = -1
if temp != "N/A" and temp is not None:
temp = float(temp)
else:
temp = None
if enc != "N/A":
enc_util = enc[0]
else:
enc_util = -1
if dec != "N/A":
dec_util = dec[0]
else:
dec_util = -1
results[i] = {
"name": gpu_name,
"gpu": gpu_util,
"mem": gpu_mem_util,
"enc": enc_util,
"dec": dec_util,
"pstate": pstate or "unknown",
"temp": temp,
}
except Exception:
pass
finally:
return results
def get_jetson_stats() -> dict[str, str] | None:
results: dict[str, str] = {}
try:
results["mem"] = "-" # no discrete gpu memory
if os.path.exists("/sys/devices/gpu.0/load"):
with open("/sys/devices/gpu.0/load") as f:
gpuload = float(f.readline()) / 10
results["gpu"] = f"{gpuload}%"
elif os.path.exists("/sys/devices/platform/gpu.0/load"):
with open("/sys/devices/platform/gpu.0/load") as f:
gpuload = float(f.readline()) / 10
results["gpu"] = f"{gpuload}%"
else:
results["gpu"] = "-"
except Exception:
return None
return results
def get_hailo_temps() -> dict[str, float]:
"""Get temperatures for Hailo devices."""
try:
from hailo_platform import Device
except ModuleNotFoundError:
return {}
temps = {}
try:
device_ids = Device.scan()
for i, device_id in enumerate(device_ids):
try:
with Device(device_id) as device:
temp_info = device.control.get_chip_temperature()
# Get board name and normalise it
identity = device.control.identify()
board_name = None
for line in str(identity).split("\n"):
if line.startswith("Board Name:"):
board_name = (
line.split(":", 1)[1].strip().lower().replace("-", "")
)
break
if not board_name:
board_name = f"hailo{i}"
# Use indexed name if multiple devices, otherwise just the board name
device_name = (
f"{board_name}-{i}" if len(device_ids) > 1 else board_name
)
# ts1_temperature is also available, but appeared to be the same as ts0 in testing.
temps[device_name] = round(temp_info.ts0_temperature, 1)
except Exception as e:
logger.debug(
f"Failed to get temperature for Hailo device {device_id}: {e}"
)
continue
except Exception as e:
logger.debug(f"Failed to scan for Hailo devices: {e}")
return temps
# Snapshot: environment_vars lands in os.environ after import and must not
# be able to enable this.
_GO2RTC_ARBITRARY_EXEC_ENV = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
def is_go2rtc_arbitrary_exec_allowed() -> bool:
"""Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker
secrets, or the Home Assistant add-on options file."""
raw: str | None = None
if _GO2RTC_ARBITRARY_EXEC_ENV is not None:
raw = _GO2RTC_ARBITRARY_EXEC_ENV
elif (
os.path.isdir("/run/secrets")
and os.access("/run/secrets", os.R_OK)
and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets")
):
try:
with open("/run/secrets/GO2RTC_ALLOW_ARBITRARY_EXEC") as f:
raw = f.read().strip()
except OSError:
raw = None
elif os.path.isfile("/data/options.json"):
try:
with open("/data/options.json") as f:
options = json.loads(f.read())
raw = options.get("go2rtc_allow_arbitrary_exec")
except (OSError, json.JSONDecodeError):
raw = None
return raw is not None and str(raw).lower() in ("true", "1", "yes")
def is_restricted_go2rtc_source(stream_source: str) -> bool:
"""Check if a stream source is a restricted type (echo, expr, or exec)
and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set."""
if not stream_source.strip().startswith(("echo:", "expr:", "exec:")):
return False
return not is_go2rtc_arbitrary_exec_allowed()
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
"""Run ffprobe on stream."""
clean_path = escape_special_characters(path)
# Base entries that are always included
stream_entries = "codec_long_name,width,height,bit_rate,duration,display_aspect_ratio,avg_frame_rate"
# Additional detailed entries
if detailed:
stream_entries += ",codec_name,profile,level,pix_fmt,channels,sample_rate,channel_layout,r_frame_rate"
format_entries = "format_name,size,bit_rate,duration"
else:
format_entries = None
def run(rtsp_transport: str | None = None) -> sp.CompletedProcess:
cmd = [ffmpeg.ffprobe_path]
if rtsp_transport:
cmd += ["-rtsp_transport", rtsp_transport]
cmd += [
"-timeout",
"1000000",
"-print_format",
"json",
"-show_entries",
f"stream={stream_entries}",
]
if detailed and format_entries:
cmd.extend(["-show_entries", f"format={format_entries}"])
cmd.extend(["-loglevel", "error", clean_path])
try:
return sp.run(cmd, capture_output=True, timeout=6)
except sp.TimeoutExpired as e:
logger.info(
"ffprobe timed out while probing %s (transport=%s)",
clean_camera_user_pass(path),
rtsp_transport or "default",
)
return sp.CompletedProcess(
args=cmd,
returncode=1,
stdout=e.stdout or b"",
stderr=(e.stderr or b"") + b"\nffprobe timed out",
)
result = run()
# For RTSP: retry with explicit TCP transport if the first attempt failed
# (default UDP may be blocked)
if result.returncode != 0 and clean_path.startswith("rtsp://"):
result = run(rtsp_transport="tcp")
return result
KEYFRAME_PROBE_WINDOW_SECONDS = 20
KEYFRAME_GAP_WARNING_SECONDS = 4.0
KEYFRAME_GAP_JITTER_SECONDS = 0.5
def parse_keyframe_packets(output: str) -> tuple[list[float], float | None]:
"""Parse ffprobe CSV `pts_time,flags` output.
Returns the presentation timestamps of keyframes (flags containing "K")
and the maximum timestamp observed across all packets.
"""
keyframe_pts: list[float] = []
max_pts: float | None = None
for line in output.splitlines():
parts = line.split(",")
if len(parts) < 2:
continue
try:
pts = float(parts[0])
except ValueError:
continue
if max_pts is None or pts > max_pts:
max_pts = pts
if "K" in parts[1]:
keyframe_pts.append(pts)
return keyframe_pts, max_pts
def classify_keyframe_gaps(
keyframe_pts: list[float], segment_time: int
) -> dict[str, Any]:
"""Classify keyframe spacing for recording suitability.
A camera using a smart/+ codec or a long/variable GOP produces large or
irregular gaps between keyframes, which breaks time-based recording
segmentation. Severity:
- "unknown" when fewer than two keyframes were observed
- "error" when the longest gap exceeds the record segment length
- "warning" when the longest gap exceeds the warning threshold
- "ok" otherwise
The "pattern" key separates the two causes so callers can give accurate
advice: "fixed" is a regular GOP that is simply too long, "variable" is
the irregular spacing a smart/+ codec produces.
"""
thresholds = {
"warning": KEYFRAME_GAP_WARNING_SECONDS,
"error": segment_time,
}
if len(keyframe_pts) < 2:
return {
"keyframe_count": len(keyframe_pts),
"max_gap": None,
"mean_gap": None,
"min_gap": None,
"pattern": None,
"segment_time": segment_time,
"severity": "unknown",
"thresholds": thresholds,
}
gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])]
max_gap = max(gaps)
min_gap = min(gaps)
if max_gap > segment_time:
severity = "error"
elif max_gap > KEYFRAME_GAP_WARNING_SECONDS:
severity = "warning"
else:
severity = "ok"
# allow for encoder jitter and probe rounding before calling a GOP variable
tolerance = max(KEYFRAME_GAP_JITTER_SECONDS, min_gap * 0.25)
pattern = "variable" if (max_gap - min_gap) > tolerance else "fixed"
return {
"keyframe_count": len(keyframe_pts),
"max_gap": round(max_gap, 2),
"mean_gap": round(sum(gaps) / len(gaps), 2),
"min_gap": round(min_gap, 2),
"pattern": pattern,
"segment_time": segment_time,
"severity": severity,
"thresholds": thresholds,
}
async def analyze_record_keyframes(
ffmpeg, url: str, segment_time: int, window: int = KEYFRAME_PROBE_WINDOW_SECONDS
) -> dict[str, Any]:
"""Probe a stream for ~`window` seconds and classify its keyframe spacing.
Reads video packet flags via ffprobe to find keyframes, then measures the
gaps between them. On timeout or failure returns an "unknown" result rather
than a false all-clear.
"""
clean_url = escape_special_characters(url)
cmd = [
ffmpeg.ffprobe_path,
"-v",
"error",
"-select_streams",
"v:0",
"-read_intervals",
f"%+{window}",
"-show_entries",
"packet=pts_time,flags",
"-of",
"csv=p=0",
clean_url,
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=window + 15)
except TimeoutError:
logger.warning("Keyframe probe timed out for record stream")
proc.kill()
return classify_keyframe_gaps([], segment_time)
except OSError as err:
logger.error("Keyframe probe failed: %s", err)
return classify_keyframe_gaps([], segment_time)
keyframe_pts, max_pts = parse_keyframe_packets(stdout.decode("utf-8", "replace"))
result = classify_keyframe_gaps(keyframe_pts, segment_time)
result["duration_observed"] = round(max_pts, 2) if max_pts is not None else None
return result
def vainfo_hwaccel(device_name: str | None = None) -> sp.CompletedProcess:
"""Run vainfo."""
if not device_name:
cmd = ["vainfo"]
else:
if os.path.isabs(device_name) and device_name.startswith("/dev/dri/"):
device_path = device_name
else:
device_path = f"/dev/dri/{device_name}"
cmd = ["vainfo", "--display", "drm", "--device", device_path]
return sp.run(cmd, capture_output=True)
def get_nvidia_driver_info() -> dict[str, Any]:
"""Get general hardware info for nvidia GPU."""
results = {}
try:
nvml.nvmlInit()
deviceCount = nvml.nvmlDeviceGetCount()
for i in range(deviceCount):
handle = nvml.nvmlDeviceGetHandleByIndex(i)
driver = try_get_info(nvml.nvmlSystemGetDriverVersion, None, default=None)
cuda_compute = try_get_info(
nvml.nvmlDeviceGetCudaComputeCapability, handle, default=None
)
vbios = try_get_info(nvml.nvmlDeviceGetVbiosVersion, handle, default=None)
results[i] = {
"name": nvml.nvmlDeviceGetName(handle),
"driver": driver or "unknown",
"cuda_compute": cuda_compute or "unknown",
"vbios": vbios or "unknown",
}
except Exception:
pass
finally:
return results
def auto_detect_hwaccel() -> str:
"""Detect hwaccel args by default."""
try:
cuda = False
vaapi = False
resp = requests.get("http://127.0.0.1:1984/api/ffmpeg/hardware", timeout=3)
if resp.status_code == 200:
data: dict[str, list[dict[str, str]]] = resp.json()
for source in data.get("sources", []):
if "cuda" in source.get("url", "") and source.get("name") == "OK":
cuda = True
if "vaapi" in source.get("url", "") and source.get("name") == "OK":
vaapi = True
except requests.RequestException:
pass
if cuda:
logger.info("Automatically detected nvidia hwaccel for video decoding")
return FFMPEG_HWACCEL_NVIDIA
if vaapi:
logger.info("Automatically detected vaapi hwaccel for video decoding")
return FFMPEG_HWACCEL_VAAPI
logger.warning(
"Did not detect hwaccel, using a GPU for accelerated video decoding is highly recommended"
)
return ""
async def get_video_properties(
ffmpeg, url: str, get_duration: bool = False
) -> dict[str, Any]:
async def probe_with_ffprobe(
url: str,
rtsp_transport: str | None = None,
) -> tuple[
bool,
int,
int,
str | None,
str | None,
float,
bool | None,
int | None,
str | None,
]:
"""Probe using ffprobe: returns (valid, width, height, fourcc, video_codec, duration, has_audio, audio_rate, audio_codec).
ffprobe reports the codec name directly, so fourcc and
video_codec are the same value on this path.
"""
cmd = [ffmpeg.ffprobe_path]
if rtsp_transport:
cmd += ["-rtsp_transport", rtsp_transport]
cmd += [
"-rw_timeout",
"5000000",
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
"-show_streams",
url,
]
proc = None
try:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=6)
except TimeoutError:
logger.info(
"ffprobe timed out while probing %s (transport=%s)",
clean_camera_user_pass(url),
rtsp_transport or "default",
)
return False, 0, 0, None, None, -1, None, None, None
if proc.returncode != 0:
return False, 0, 0, None, None, -1, None, None, None
data = json.loads(stdout.decode())
video_streams = [
s for s in data.get("streams", []) if s.get("codec_type") == "video"
]
if not video_streams:
return False, 0, 0, None, None, -1, None, None, None
v = video_streams[0]
width = int(v.get("width", 0))
height = int(v.get("height", 0))
codec = v.get("codec_name")
duration_str = data.get("format", {}).get("duration")
duration = float(duration_str) if duration_str else -1.0
audio_streams = [
s for s in data.get("streams", []) if s.get("codec_type") == "audio"
]
has_audio = bool(audio_streams)
# codec and sample rate distinguish audio tracks whose decoder
# configs cannot share an HLS sequence
audio_rate: int | None = None
audio_codec: str | None = None
if audio_streams:
try:
audio_rate = int(audio_streams[0]["sample_rate"])
except (KeyError, TypeError, ValueError):
audio_rate = None
audio_codec = audio_streams[0].get("codec_name")
return (
True,
width,
height,
codec,
codec,
duration,
has_audio,
audio_rate,
audio_codec,
)
except (json.JSONDecodeError, ValueError, KeyError, sp.SubprocessError):
return False, 0, 0, None, None, -1, None, None, None
finally:
# callers run in a per-cycle event loop, and an ffprobe still
# running when that loop closes is finalized against a dead loop
# ("Event loop is closed"). Draining after the kill is what
# closes the pipes and their transports
if proc is not None and proc.returncode is None:
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(proc.communicate(), timeout=2)
def probe_with_cv2(
url: str,
) -> tuple[
bool,
int,
int,
str | None,
str | None,
float,
bool | None,
int | None,
str | None,
]:
"""Probe using cv2: returns (valid, width, height, fourcc, video_codec, duration, has_audio, audio_rate, audio_codec).
cv2 cannot report audio streams or a normalized codec name, so
has_audio, audio_rate, audio_codec, and video_codec are always
None (unknown) on this path.
"""
cap = cv2.VideoCapture(url)
if not cap.isOpened():
cap.release()
return False, 0, 0, None, None, -1, None, None, None
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
valid = width > 0 and height > 0
fourcc = None
duration = -1.0
if valid:
fourcc_int = int(cap.get(cv2.CAP_PROP_FOURCC))
fourcc = fourcc_int.to_bytes(4, "little").decode("latin-1").strip()
if get_duration:
fps = cap.get(cv2.CAP_PROP_FPS)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
if fps > 0 and total_frames > 0:
duration = total_frames / fps
cap.release()
return valid, width, height, fourcc, None, duration, None, None, None
is_rtsp = url.startswith("rtsp://")
if is_rtsp:
# skip cv2 for RTSP: its FFmpeg backend has a hardcoded ~30s internal
# timeout that cannot be shortened per-call, and ffprobe bounded by
# -rw_timeout handles RTSP probing reliably
(
has_video,
width,
height,
fourcc,
video_codec,
duration,
has_audio,
audio_rate,
audio_codec,
) = await probe_with_ffprobe(url)
elif get_duration:
# ffprobe first: segment validation also needs audio presence,
# which cv2 cannot report
(
has_video,
width,
height,
fourcc,
video_codec,
duration,
has_audio,
audio_rate,
audio_codec,
) = await probe_with_ffprobe(url)
# fallback to cv2 if needed; audio stays unknown there
if not has_video or duration < 0:
(
has_video,
width,
height,
fourcc,
video_codec,
duration,
has_audio,
audio_rate,
audio_codec,
) = probe_with_cv2(url)
else:
# try cv2 first for local files, HTTP, RTMP
(
has_video,
width,
height,
fourcc,
video_codec,
duration,
has_audio,
audio_rate,
audio_codec,
) = probe_with_cv2(url)
# fallback to ffprobe if needed
if not has_video:
(
has_video,
width,
height,
fourcc,
video_codec,
duration,
has_audio,
audio_rate,
audio_codec,
) = await probe_with_ffprobe(url)
# last resort for RTSP: try TCP transport, since default UDP may be blocked
if (not has_video or (get_duration and duration < 0)) and is_rtsp:
(
has_video,
width,
height,
fourcc,
video_codec,
duration,
has_audio,
audio_rate,
audio_codec,
) = await probe_with_ffprobe(url, rtsp_transport="tcp")
result: dict[str, Any] = {"has_valid_video": has_video}
if has_video:
result.update(
{
"width": width,
"height": height,
"has_audio": has_audio,
"audio_rate": audio_rate,
"audio_codec": audio_codec,
"video_codec": video_codec,
}
)
if fourcc:
result["fourcc"] = fourcc
if get_duration:
result["duration"] = duration
return result
def process_logs(
contents: str,
service: str | None = None,
start: int | None = None,
end: int | None = None,
) -> tuple[int, list[str]]:
log_lines = []
last_message = None
last_timestamp = None
repeat_count = 0
for raw_line in contents.splitlines():
clean_line = raw_line.strip()
if len(clean_line) < 10:
continue
# Handle cases where S6 does not include date in log line
if " " not in clean_line:
clean_line = f"{datetime.now()} {clean_line}"
try:
# Find the position of the first double space to extract timestamp and message
date_end = clean_line.index(" ")
timestamp = clean_line[:date_end]
full_message = clean_line[date_end:].strip()
# For frigate, remove the date part from message comparison
if service == "frigate":
# Skip the date at the start of the message if it exists
date_parts = full_message.split("]", 1)
if len(date_parts) > 1:
message_part = date_parts[1].strip()
else:
message_part = full_message
else:
message_part = full_message
if message_part == last_message:
repeat_count += 1
continue
else:
if repeat_count > 0:
# Insert a deduplication message formatted the same way as logs
dedup_message = f"{last_timestamp} [LOGGING] Last message repeated {repeat_count} times"
log_lines.append(dedup_message)
repeat_count = 0
log_lines.append(clean_line)
last_timestamp = timestamp
last_message = message_part
except ValueError:
# If we can't parse the line properly, just add it as is
log_lines.append(clean_line)
continue
# If there were repeated messages at the end, log the count
if repeat_count > 0:
dedup_message = (
f"{last_timestamp} [LOGGING] Last message repeated {repeat_count} times"
)
log_lines.append(dedup_message)
return len(log_lines), log_lines[start:end]
def set_file_limit() -> None:
# Newer versions of containerd 2.X+ impose a very low soft file limit of 1024
# This applies to OSs like HA OS (see https://github.com/home-assistant/operating-system/issues/4110)
# Attempt to increase this limit
soft_limit = int(os.getenv("SOFT_FILE_LIMIT", "65536") or "65536")
current_soft, current_hard = resource.getrlimit(resource.RLIMIT_NOFILE)
logger.debug(f"Current file limits - Soft: {current_soft}, Hard: {current_hard}")
new_soft = min(soft_limit, current_hard)
resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, current_hard))
logger.debug(
f"File limit set. New soft limit: {new_soft}, Hard limit remains: {current_hard}"
)
def get_fs_type(path: str) -> str:
bestMatch = ""
fsType = ""
for part in psutil.disk_partitions(all=True):
if path.startswith(part.mountpoint) and len(bestMatch) < len(part.mountpoint):
fsType = part.fstype
bestMatch = part.mountpoint
return fsType
def calculate_shm_requirements(config) -> dict:
try:
storage_stats = shutil.disk_usage("/dev/shm")
except (FileNotFoundError, OSError):
return {}
total_mb = round(storage_stats.total / pow(2, 20), 1)
used_mb = round(storage_stats.used / pow(2, 20), 1)
free_mb = round(storage_stats.free / pow(2, 20), 1)
# required for log files + nginx cache
min_req_shm = 40 + 10
if config.birdseye.restream:
min_req_shm += 8
available_shm = total_mb - min_req_shm
cam_total_frame_size = 0.0
for camera in config.cameras.values():
if camera.enabled_in_config and camera.detect.width and camera.detect.height:
cam_total_frame_size += round(
(camera.detect.width * camera.detect.height * 1.5 + 270480) / 1048576,
1,
)
# leave room for 2 cameras that are added dynamically, if a user wants to add more cameras they may need to increase the SHM size and restart after adding them.
cam_total_frame_size += 2 * round(
(1280 * 720 * 1.5 + 270480) / 1048576,
1,
)
shm_frame_count = min(
int(os.environ.get(SHM_FRAMES_VAR, "50")),
int(available_shm / cam_total_frame_size),
)
# minimum required shm recommendation
min_shm = round(min_req_shm + cam_total_frame_size * 20)
return {
"total": total_mb,
"used": used_mb,
"free": free_mb,
"mount_type": get_fs_type("/dev/shm"),
"available": round(available_shm, 1),
"camera_frame_size": cam_total_frame_size,
"shm_frame_count": shm_frame_count,
"min_shm": min_shm,
}