#!/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()