Files
hik2webcam/snapshot.py
2026-07-31 21:37:47 +02:00

279 lines
9.4 KiB
Python

#!/usr/bin/env python3
"""Download current images from a Hikvision NVR or publish a placeholder."""
from __future__ import annotations
import json
import logging
import os
import shutil
import ssl
import sys
import tempfile
from dataclasses import dataclass
from datetime import datetime, time
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urljoin
from urllib.request import (
HTTPBasicAuthHandler,
HTTPDigestAuthHandler,
HTTPSHandler,
HTTPPasswordMgrWithDefaultRealm,
Request,
build_opener,
)
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
LOG = logging.getLogger("nvr-snapshot")
class ConfigError(ValueError):
pass
@dataclass(frozen=True)
class Snapshot:
channel: int
output: Path
stream_id: str = "01"
def load_dotenv(path: Path) -> None:
"""Load a small, conventional subset of .env syntax without dependencies."""
if not path.exists():
return
for number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[7:].lstrip()
if "=" not in line:
raise ConfigError(f"{path}:{number}: očekávám KEY=VALUE")
key, value = line.split("=", 1)
key, value = key.strip(), value.strip()
if not key:
raise ConfigError(f"{path}:{number}: prázdný název proměnné")
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
value = value[1:-1]
os.environ.setdefault(key, value)
def env(name: str, default: str | None = None, *, required: bool = False) -> str:
value = os.environ.get(name, default)
if required and not value:
raise ConfigError(f"Chybí povinná proměnná {name}")
assert value is not None
return value
def env_bool(name: str, default: bool) -> bool:
raw = env(name, "true" if default else "false").lower()
if raw in {"1", "true", "yes", "on"}:
return True
if raw in {"0", "false", "no", "off"}:
return False
raise ConfigError(f"{name} musí být true/false")
def parse_clock(value: str, name: str) -> time:
try:
return time.fromisoformat(value)
except ValueError as exc:
raise ConfigError(f"{name} musí být ve formátu HH:MM nebo HH:MM:SS") from exc
def is_active(now: datetime, start: time, end: time) -> bool:
current = now.timetz().replace(tzinfo=None)
if start == end:
return True
if start < end:
return start <= current < end
return current >= start or current < end
def snapshots_from_env(base_dir: Path) -> list[Snapshot]:
raw = os.environ.get("SNAPSHOTS")
if raw:
try:
items = json.loads(raw)
except json.JSONDecodeError as exc:
raise ConfigError(f"SNAPSHOTS není platné JSON: {exc}") from exc
if not isinstance(items, list) or not items:
raise ConfigError("SNAPSHOTS musí být neprázdné JSON pole")
else:
items = [{
"channel": int(env("NVR_CHANNEL", "1")),
"stream_id": env("NVR_STREAM_ID", "01"),
"output": env("OUTPUT_PATH", required=True),
}]
result = []
for index, item in enumerate(items):
if not isinstance(item, dict):
raise ConfigError(f"SNAPSHOTS[{index}] musí být objekt")
try:
channel = int(item["channel"])
output = Path(str(item["output"]))
stream_id = str(item.get("stream_id", "01"))
except (KeyError, TypeError, ValueError) as exc:
raise ConfigError(
f"SNAPSHOTS[{index}] vyžaduje channel (číslo) a output"
) from exc
if channel < 1 or not stream_id.isdigit():
raise ConfigError(f"Neplatný kanál/stream v SNAPSHOTS[{index}]")
if not output.is_absolute():
output = base_dir / output
result.append(Snapshot(channel, output.resolve(), stream_id))
return result
def atomic_copy(source: Path, destination: Path) -> None:
if not source.is_file():
raise ConfigError(f"Dummy soubor neexistuje: {source}")
destination.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(
prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent
)
os.close(fd)
temp = Path(temp_name)
try:
shutil.copyfile(source, temp)
os.replace(temp, destination)
finally:
temp.unlink(missing_ok=True)
def atomic_write(data: bytes, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(
prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent
)
temp = Path(temp_name)
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, destination)
finally:
temp.unlink(missing_ok=True)
def make_opener(base_url: str, username: str, password: str, verify_tls: bool):
manager = HTTPPasswordMgrWithDefaultRealm()
manager.add_password(None, base_url, username, password)
context = ssl.create_default_context()
if not verify_tls:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return build_opener(
HTTPDigestAuthHandler(manager),
HTTPBasicAuthHandler(manager),
HTTPSHandler(context=context),
)
def validate_image(data: bytes, content_type: str) -> None:
signatures = (
data.startswith(b"\xff\xd8\xff"),
data.startswith(b"\x89PNG\r\n\x1a\n"),
data.startswith((b"GIF87a", b"GIF89a")),
data.startswith(b"BM"),
)
if not data or not any(signatures):
detail = data[:160].decode("utf-8", errors="replace").replace("\n", " ")
raise RuntimeError(
f"NVR nevrátil podporovaný obrázek (Content-Type {content_type!r}): {detail}"
)
def run() -> int:
script_dir = Path(__file__).resolve().parent
dotenv_path = Path(os.environ.get("ENV_FILE", script_dir / ".env"))
load_dotenv(dotenv_path)
log_level = env("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=getattr(logging, log_level, logging.INFO),
format="%(asctime)s %(levelname)s %(message)s",
)
protocol = env("NVR_PROTOCOL", "https").lower()
if protocol not in {"http", "https"}:
raise ConfigError("NVR_PROTOCOL musí být http nebo https")
host = env("NVR_HOST", required=True)
port = int(env("NVR_PORT", "443" if protocol == "https" else "80"))
base_url = f"{protocol}://{host}:{port}/"
username = env("NVR_USERNAME", required=True)
password = env("NVR_PASSWORD", required=True)
timeout = float(env("NVR_TIMEOUT", "15"))
verify_tls = env_bool("NVR_VERIFY_TLS", True)
path_template = env(
"NVR_SNAPSHOT_PATH", "ISAPI/Streaming/channels/{channel}{stream_id}/picture"
)
snapshot_width = int(env("NVR_SNAPSHOT_WIDTH", "1920"))
snapshot_height = int(env("NVR_SNAPSHOT_HEIGHT", "1080"))
if snapshot_width < 1 or snapshot_height < 1:
raise ConfigError("NVR_SNAPSHOT_WIDTH a NVR_SNAPSHOT_HEIGHT musí být kladné")
try:
timezone = ZoneInfo(env("TIMEZONE", "Europe/Prague"))
except ZoneInfoNotFoundError as exc:
raise ConfigError("Neznámá TIMEZONE; použijte např. Europe/Prague") from exc
start = parse_clock(env("ACTIVE_FROM", "00:00"), "ACTIVE_FROM")
end = parse_clock(env("ACTIVE_TO", "00:00"), "ACTIVE_TO")
snapshots = snapshots_from_env(script_dir)
now = datetime.now(timezone)
dummy = Path(env("DUMMY_PATH", str(script_dir / "dummy.jpg")))
if not dummy.is_absolute():
dummy = script_dir / dummy
if not is_active(now, start, end):
for snapshot in snapshots:
atomic_copy(dummy, snapshot.output)
LOG.info("Mimo časové okno: dummy -> %s", snapshot.output)
return 0
opener = make_opener(base_url, username, password, verify_tls)
failures = 0
for snapshot in snapshots:
relative_path = path_template.format(
channel=snapshot.channel, stream_id=snapshot.stream_id
).lstrip("/")
url = urljoin(base_url, relative_path)
query = urlencode({
"videoResolutionWidth": snapshot_width,
"videoResolutionHeight": snapshot_height,
"snapShotImageType": "JPEG",
})
url = f"{url}{'&' if '?' in url else '?'}{query}"
request = Request(url, headers={"Accept": "image/jpeg,image/*;q=0.9"})
try:
with opener.open(request, timeout=timeout) as response:
data = response.read()
content_type = response.headers.get("Content-Type", "")
validate_image(data, content_type)
atomic_write(data, snapshot.output)
LOG.info("Staženo %s (%d B) -> %s", url, len(data), snapshot.output)
except (HTTPError, URLError, OSError, RuntimeError) as exc:
failures += 1
LOG.error("Stažení %s selhalo: %s", url, exc)
return 1 if failures else 0
def main() -> None:
try:
raise SystemExit(run())
except (ConfigError, ValueError) as exc:
logging.basicConfig(level=logging.ERROR, format="%(levelname)s %(message)s")
LOG.error("Chyba konfigurace: %s", exc)
raise SystemExit(2)
if __name__ == "__main__":
main()