prvni commit
This commit is contained in:
34
.env.example
Normal file
34
.env.example
Normal file
@@ -0,0 +1,34 @@
|
||||
# Připojení k Hikvision NVR
|
||||
NVR_PROTOCOL=https
|
||||
NVR_HOST=192.168.1.100
|
||||
NVR_PORT=443
|
||||
NVR_USERNAME=webcam
|
||||
NVR_PASSWORD=zmente-me
|
||||
|
||||
# U interního NVR se self-signed certifikátem nastavte false.
|
||||
NVR_VERIFY_TLS=false
|
||||
NVR_TIMEOUT=15
|
||||
|
||||
# Kanál 1 + stream 01 vytvoří ISAPI identifikátor 101.
|
||||
NVR_CHANNEL=1
|
||||
NVR_STREAM_ID=01
|
||||
# Požadované rozlišení výsledného snapshotu.
|
||||
NVR_SNAPSHOT_WIDTH=1920
|
||||
NVR_SNAPSHOT_HEIGHT=1080
|
||||
OUTPUT_PATH=public/webcam.jpg
|
||||
|
||||
# Alternativa pro více obrázků (pak se tři řádky výše ignorují):
|
||||
# SNAPSHOTS=[{"channel":1,"stream_id":"01","output":"public/vjezd.jpg"},{"channel":2,"stream_id":"01","output":"public/parkoviste.jpg"}]
|
||||
|
||||
# Společné denní časové okno. Shodné časy znamenají celý den.
|
||||
# Funguje také interval přes půlnoc, např. 20:00 až 06:00.
|
||||
TIMEZONE=Europe/Prague
|
||||
ACTIVE_FROM=06:00
|
||||
ACTIVE_TO=20:00
|
||||
|
||||
# Mimo časové okno se tento soubor atomicky zkopíruje do každého OUTPUT_PATH.
|
||||
DUMMY_PATH=dummy.jpg
|
||||
|
||||
# Endpoint lze změnit, pokud firmware používá jinou cestu.
|
||||
NVR_SNAPSHOT_PATH=ISAPI/Streaming/channels/{channel}{stream_id}/picture
|
||||
LOG_LEVEL=INFO
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
*.log
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
59
README.md
Normal file
59
README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Hikvision NVR webcam snapshot
|
||||
|
||||
Jednoduchý skript stáhne aktuální obrázek z kanálu Hikvision NVR a atomicky jej
|
||||
publikuje do zadaného souboru na webu. Mimo společné časové okno publikuje
|
||||
připravený dummy obrázek. Podporuje jeden i více kanálů.
|
||||
|
||||
## Instalace
|
||||
|
||||
Požadavek je Python 3.9 nebo novější, bez externích balíčků.
|
||||
|
||||
1. Zkopírujte `.env.example` jako `.env`.
|
||||
2. Doplňte adresu NVR, samostatného uživatele pouze s potřebným oprávněním,
|
||||
heslo, kanál a cílovou cestu.
|
||||
3. Vedle skriptu uložte vlastní `dummy.jpg` (nebo změňte `DUMMY_PATH`).
|
||||
4. Jednou skript spusťte ručně:
|
||||
|
||||
```sh
|
||||
python3 snapshot.py
|
||||
```
|
||||
|
||||
Heslo v `.env` může obsahovat mezery a `#`; uvozovky jsou volitelné. Soubor
|
||||
`.env` nepatří do verzovacího systému.
|
||||
|
||||
## Pravidelné spouštění
|
||||
|
||||
Skript sám čekání ani plánování neprovádí. Na Linuxu jej lze volat každou minutu:
|
||||
|
||||
```cron
|
||||
* * * * * cd /cesta/k/hik2webcam && /usr/bin/python3 snapshot.py >> snapshot.log 2>&1
|
||||
```
|
||||
|
||||
Ve Windows použijte Plánovač úloh a jako program nastavte `python.exe`, argument
|
||||
`C:\cesta\snapshot.py` a pracovní adresář projektu.
|
||||
|
||||
Shodné hodnoty `ACTIVE_FROM` a `ACTIVE_TO` znamenají nepřetržitý provoz.
|
||||
Interval může přecházet přes půlnoc. Při chybě NVR zůstane poslední platný
|
||||
obrázek zachován a proces skončí kódem `1`; mimo časové okno skončí po úspěšném
|
||||
zkopírování dummy obrázku kódem `0`.
|
||||
|
||||
## Hikvision endpoint
|
||||
|
||||
Výchozí cesta je:
|
||||
|
||||
```text
|
||||
/ISAPI/Streaming/channels/{channel}{stream_id}/picture
|
||||
```
|
||||
|
||||
Kanál `1` a stream `01` tedy používají ID `101`. Autentizace zobrazená v NVR
|
||||
jako `digest` je podporovaná; skript umí jako zálohu také Basic autentizaci.
|
||||
Pro HTTPS se self-signed certifikátem lze použít `NVR_VERIFY_TLS=false`.
|
||||
|
||||
Požadované rozlišení se posílá NVR explicitně pomocí ISAPI parametrů
|
||||
`videoResolutionWidth` a `videoResolutionHeight`. Nastavuje se globálně přes
|
||||
`NVR_SNAPSHOT_WIDTH` a `NVR_SNAPSHOT_HEIGHT`; výchozí hodnota je 1920×1080.
|
||||
NVR musí dané rozlišení podporovat, jinak může vrátit nejbližší podporované
|
||||
rozlišení nebo chybovou XML odpověď.
|
||||
|
||||
Pokud zařízení endpoint odmítne, ověřte, že uživatel smí sledovat daný kanál,
|
||||
že je povolené ISAPI a že `NVR_SNAPSHOT_PATH` odpovídá konkrétnímu firmwaru.
|
||||
278
snapshot.py
Normal file
278
snapshot.py
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/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()
|
||||
39
tests/test_snapshot.py
Normal file
39
tests/test_snapshot.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, time
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import snapshot
|
||||
|
||||
|
||||
class ScheduleTests(unittest.TestCase):
|
||||
def test_day_window(self):
|
||||
now = datetime(2026, 7, 30, 12, 0, tzinfo=ZoneInfo("Europe/Prague"))
|
||||
self.assertTrue(snapshot.is_active(now, time(6), time(20)))
|
||||
self.assertFalse(snapshot.is_active(now, time(13), time(20)))
|
||||
|
||||
def test_overnight_window(self):
|
||||
late = datetime(2026, 7, 30, 23, 0, tzinfo=ZoneInfo("Europe/Prague"))
|
||||
noon = datetime(2026, 7, 30, 12, 0, tzinfo=ZoneInfo("Europe/Prague"))
|
||||
self.assertTrue(snapshot.is_active(late, time(20), time(6)))
|
||||
self.assertFalse(snapshot.is_active(noon, time(20), time(6)))
|
||||
|
||||
def test_equal_times_mean_all_day(self):
|
||||
now = datetime(2026, 7, 30, 12, 0, tzinfo=ZoneInfo("Europe/Prague"))
|
||||
self.assertTrue(snapshot.is_active(now, time(0), time(0)))
|
||||
|
||||
|
||||
class FileTests(unittest.TestCase):
|
||||
def test_atomic_copy_replaces_destination(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "dummy.jpg"
|
||||
destination = root / "web" / "camera.jpg"
|
||||
source.write_bytes(b"dummy")
|
||||
snapshot.atomic_copy(source, destination)
|
||||
self.assertEqual(destination.read_bytes(), b"dummy")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user