279 lines
9.4 KiB
Python
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()
|