hikcloudstream 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hikcloudstream/__init__.py +39 -0
- hikcloudstream/_config.py +15 -0
- hikcloudstream/auth.py +30 -0
- hikcloudstream/capture.py +37 -0
- hikcloudstream/cli/__init__.py +1 -0
- hikcloudstream/cli/_common.py +140 -0
- hikcloudstream/cli/snapshot.py +125 -0
- hikcloudstream/cli/stream.py +226 -0
- hikcloudstream/client.py +296 -0
- hikcloudstream/exceptions.py +48 -0
- hikcloudstream/models.py +48 -0
- hikcloudstream/py.typed +0 -0
- hikcloudstream/stream/__init__.py +26 -0
- hikcloudstream/stream/adapter.py +123 -0
- hikcloudstream/stream/crypto.py +178 -0
- hikcloudstream/stream/probe.py +62 -0
- hikcloudstream/stream/rtp.py +31 -0
- hikcloudstream/stream/session.py +391 -0
- hikcloudstream/stream/sinks/__init__.py +1 -0
- hikcloudstream/stream/sinks/annex_b.py +76 -0
- hikcloudstream/stream/sinks/http.py +250 -0
- hikcloudstream/stream/sinks/mjpeg.py +126 -0
- hikcloudstream/stream/sinks/mpegts.py +167 -0
- hikcloudstream/stream/tokens.py +107 -0
- hikcloudstream-0.1.0.dist-info/METADATA +148 -0
- hikcloudstream-0.1.0.dist-info/RECORD +30 -0
- hikcloudstream-0.1.0.dist-info/WHEEL +4 -0
- hikcloudstream-0.1.0.dist-info/entry_points.txt +3 -0
- hikcloudstream-0.1.0.dist-info/licenses/LICENSE +0 -0
- hikcloudstream-0.1.0.dist-info/licenses/NOTICE +12 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Unofficial Python SDK for Hik-Connect cloud cameras."""
|
|
2
|
+
|
|
3
|
+
from hikcloudstream._config import CLOUD_CAPTURE_MAX
|
|
4
|
+
from hikcloudstream.client import HikConnectClient
|
|
5
|
+
from hikcloudstream.exceptions import (
|
|
6
|
+
ApiError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
CameraNotFoundError,
|
|
9
|
+
CaptchaRequiredError,
|
|
10
|
+
CaptureError,
|
|
11
|
+
EncryptedStreamError,
|
|
12
|
+
FFmpegNotFoundError,
|
|
13
|
+
HikCloudStreamError,
|
|
14
|
+
StreamNegotiationError,
|
|
15
|
+
TokenError,
|
|
16
|
+
)
|
|
17
|
+
from hikcloudstream.models import Camera, ClientConfig, Credentials, StreamType
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"CLOUD_CAPTURE_MAX",
|
|
23
|
+
"ApiError",
|
|
24
|
+
"AuthenticationError",
|
|
25
|
+
"Camera",
|
|
26
|
+
"CameraNotFoundError",
|
|
27
|
+
"CaptchaRequiredError",
|
|
28
|
+
"CaptureError",
|
|
29
|
+
"ClientConfig",
|
|
30
|
+
"Credentials",
|
|
31
|
+
"EncryptedStreamError",
|
|
32
|
+
"FFmpegNotFoundError",
|
|
33
|
+
"HikCloudStreamError",
|
|
34
|
+
"HikConnectClient",
|
|
35
|
+
"StreamNegotiationError",
|
|
36
|
+
"StreamType",
|
|
37
|
+
"TokenError",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Internal constants shared across modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
JPEG_MAGIC = b"\xff\xd8"
|
|
6
|
+
HIK_ENCODED_PREFIX = b"hikencodepicture"
|
|
7
|
+
CLOUD_CAPTURE_MAX = (352, 288)
|
|
8
|
+
DEVICE_FILTER = (
|
|
9
|
+
"TIME_PLAN,CONNECTION,SWITCH,STATUS,STATUS_EXT,WIFI,NODISTURB,P2P,KMS,HIDDNS"
|
|
10
|
+
)
|
|
11
|
+
RESOURCE_VTM_FILTER = "VTM"
|
|
12
|
+
ANNEX_B_START_CODE = b"\x00\x00\x00\x01"
|
|
13
|
+
MJPEG_BOUNDARY = b"hikframe"
|
|
14
|
+
DEFAULT_STREAM_TYPE = 2
|
|
15
|
+
STREAM_PROBE_TIMEOUT = 5.0
|
hikcloudstream/auth.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Authentication helpers and API response validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from hikcloudstream.exceptions import ApiError, AuthenticationError, CaptchaRequiredError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_api_meta(payload: dict[str, Any]) -> None:
|
|
11
|
+
"""Raise typed errors when the Hik-Connect meta block indicates failure."""
|
|
12
|
+
meta = payload.get("meta") or {}
|
|
13
|
+
code = meta.get("code", 0)
|
|
14
|
+
if code == 200:
|
|
15
|
+
return
|
|
16
|
+
message = str(meta.get("message") or meta.get("langMsg") or "unknown error")
|
|
17
|
+
raise ApiError(int(code), message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def raise_login_error(payload: dict[str, Any]) -> None:
|
|
21
|
+
"""Map login response codes to typed authentication errors."""
|
|
22
|
+
meta = payload.get("meta") or {}
|
|
23
|
+
code = meta.get("code")
|
|
24
|
+
if code in (1013, 1014):
|
|
25
|
+
raise AuthenticationError("invalid username or password")
|
|
26
|
+
if code == 1015:
|
|
27
|
+
raise CaptchaRequiredError(
|
|
28
|
+
"CAPTCHA required — log in once via the Hik-Connect app, then retry"
|
|
29
|
+
)
|
|
30
|
+
check_api_meta(payload)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Cloud snapshot decryption helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from Crypto.Cipher import AES
|
|
6
|
+
from Crypto.Util.Padding import unpad
|
|
7
|
+
|
|
8
|
+
from hikcloudstream._config import HIK_ENCODED_PREFIX, JPEG_MAGIC
|
|
9
|
+
from hikcloudstream.exceptions import CaptureError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def aes_key_material(value: str | None) -> bytes:
|
|
13
|
+
raw = (value or "").encode("utf-8")
|
|
14
|
+
if len(raw) >= 16:
|
|
15
|
+
return raw[:16]
|
|
16
|
+
return raw + b"\x00" * (16 - len(raw))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def decrypt_capture(raw: bytes, validate_code: str | None) -> bytes:
|
|
20
|
+
"""Decrypt Hik-Connect encrypted cloud snapshots."""
|
|
21
|
+
if raw.startswith(JPEG_MAGIC):
|
|
22
|
+
return raw
|
|
23
|
+
if not raw.startswith(HIK_ENCODED_PREFIX):
|
|
24
|
+
raise CaptureError(
|
|
25
|
+
"unknown image format; provide validate_code for encrypted devices"
|
|
26
|
+
)
|
|
27
|
+
if len(raw) <= 48:
|
|
28
|
+
raise CaptureError("encrypted payload too small")
|
|
29
|
+
|
|
30
|
+
key = aes_key_material(validate_code)
|
|
31
|
+
iv = aes_key_material("01234567")
|
|
32
|
+
cipher = AES.new(key, AES.MODE_CBC, iv)
|
|
33
|
+
decrypted = cipher.decrypt(raw[48:])
|
|
34
|
+
try:
|
|
35
|
+
return unpad(decrypted, AES.block_size)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return decrypted
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional CLI entry points (install with [cli] extra)."""
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Shared CLI helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from hikcloudstream._config import JPEG_MAGIC
|
|
14
|
+
from hikcloudstream.exceptions import CameraNotFoundError
|
|
15
|
+
from hikcloudstream.models import Camera, ClientConfig, Credentials
|
|
16
|
+
|
|
17
|
+
DEFAULT_API = ClientConfig().api_base_url
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def credentials_from_args(args: argparse.Namespace) -> Credentials:
|
|
21
|
+
username = args.username or os.environ.get("HIK_CONNECT_USER", "")
|
|
22
|
+
password = args.password or os.environ.get("HIK_CONNECT_PASSWORD", "")
|
|
23
|
+
if not username or not password:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
"username and password required (arguments or HIK_CONNECT_USER / HIK_CONNECT_PASSWORD)"
|
|
26
|
+
)
|
|
27
|
+
return Credentials(username=username, password=password)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def add_auth_args(parser: argparse.ArgumentParser) -> None:
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"username",
|
|
33
|
+
nargs="?",
|
|
34
|
+
default=None,
|
|
35
|
+
help="Hik-Connect account (or HIK_CONNECT_USER env)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"password",
|
|
39
|
+
nargs="?",
|
|
40
|
+
default=None,
|
|
41
|
+
help="Hik-Connect password (or HIK_CONNECT_PASSWORD env)",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"camera",
|
|
45
|
+
nargs="?",
|
|
46
|
+
type=int,
|
|
47
|
+
help="Camera number from --list (1-based index)",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--list",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="List available cameras and exit",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--json",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Print camera list as JSON (with --list)",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--api",
|
|
61
|
+
default=DEFAULT_API,
|
|
62
|
+
help=f"API base URL (default: {DEFAULT_API})",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_camera(cameras: list[Camera], camera_no: int) -> Camera:
|
|
67
|
+
if camera_no < 1 or camera_no > len(cameras):
|
|
68
|
+
raise CameraNotFoundError(
|
|
69
|
+
f"invalid camera {camera_no}. Use --list (1..{len(cameras)})."
|
|
70
|
+
)
|
|
71
|
+
return cameras[camera_no - 1]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def print_camera_list(cameras: list[Camera]) -> None:
|
|
75
|
+
if not cameras:
|
|
76
|
+
print("No cameras found for this account.")
|
|
77
|
+
return
|
|
78
|
+
print(f"{'#':>3} {'Camera':<28} {'Device':<20} {'Serial':<12} Ch")
|
|
79
|
+
print("-" * 80)
|
|
80
|
+
for camera in cameras:
|
|
81
|
+
print(
|
|
82
|
+
f"{camera.index:>3} {camera.name[:28]:<28} "
|
|
83
|
+
f"{camera.device_name[:20]:<20} {camera.device_serial:<12} "
|
|
84
|
+
f"{camera.channel_no}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def dump_camera_list_json(cameras: list[Camera]) -> None:
|
|
89
|
+
print(
|
|
90
|
+
json.dumps(
|
|
91
|
+
[
|
|
92
|
+
{
|
|
93
|
+
"index": c.index,
|
|
94
|
+
"name": c.name,
|
|
95
|
+
"device": c.device_name,
|
|
96
|
+
"deviceSerial": c.device_serial,
|
|
97
|
+
"channelNo": c.channel_no,
|
|
98
|
+
}
|
|
99
|
+
for c in cameras
|
|
100
|
+
],
|
|
101
|
+
indent=2,
|
|
102
|
+
ensure_ascii=False,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def image_dimensions(image_bytes: bytes) -> tuple[int, int]:
|
|
108
|
+
from PIL import Image
|
|
109
|
+
|
|
110
|
+
image = Image.open(io.BytesIO(image_bytes))
|
|
111
|
+
return image.size
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_image(image_bytes: bytes, path: Path) -> Path:
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
path.write_bytes(image_bytes)
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def show_image(image_bytes: bytes) -> None:
|
|
121
|
+
from PIL import Image
|
|
122
|
+
|
|
123
|
+
image = Image.open(io.BytesIO(image_bytes))
|
|
124
|
+
if image.mode not in ("RGB", "L"):
|
|
125
|
+
image = image.convert("RGB")
|
|
126
|
+
image.show()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def open_path(path: Path) -> None:
|
|
130
|
+
if sys.platform.startswith("linux"):
|
|
131
|
+
subprocess.run(["xdg-open", str(path)], check=False)
|
|
132
|
+
elif sys.platform == "darwin":
|
|
133
|
+
subprocess.run(["open", str(path)], check=False)
|
|
134
|
+
else:
|
|
135
|
+
raise RuntimeError("no default viewer configured for this platform")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def ensure_jpeg(image_bytes: bytes) -> None:
|
|
139
|
+
if not image_bytes.startswith(JPEG_MAGIC):
|
|
140
|
+
raise RuntimeError("output is not a valid JPEG")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cloud snapshot CLI — low-resolution thumbnail via Hik-Connect API."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from hikcloudstream import CLOUD_CAPTURE_MAX, HikConnectClient
|
|
13
|
+
from hikcloudstream.cli._common import (
|
|
14
|
+
add_auth_args,
|
|
15
|
+
credentials_from_args,
|
|
16
|
+
dump_camera_list_json,
|
|
17
|
+
ensure_jpeg,
|
|
18
|
+
image_dimensions,
|
|
19
|
+
open_path,
|
|
20
|
+
print_camera_list,
|
|
21
|
+
resolve_camera,
|
|
22
|
+
save_image,
|
|
23
|
+
show_image,
|
|
24
|
+
)
|
|
25
|
+
from hikcloudstream.exceptions import HikCloudStreamError
|
|
26
|
+
from hikcloudstream.models import ClientConfig
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
description="Capture a cloud snapshot from Hik-Connect cameras.",
|
|
32
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
33
|
+
epilog=(
|
|
34
|
+
"Examples:\n"
|
|
35
|
+
" hikcloudstream-snapshot user pass --list\n"
|
|
36
|
+
" hikcloudstream-snapshot user pass 3\n"
|
|
37
|
+
" hikcloudstream-snapshot user pass 3 -o camera3.jpg\n"
|
|
38
|
+
" hikcloudstream-stream user pass 3 -o frame-hd.jpg # higher-res live frame\n"
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
add_auth_args(parser)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"-o",
|
|
44
|
+
"--output",
|
|
45
|
+
type=Path,
|
|
46
|
+
help="Save snapshot to this file (default: camera-<n>.jpg)",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--show",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Open the snapshot with the default image viewer",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--validate-code",
|
|
55
|
+
help="Device encryption code (only needed for encrypted snapshots)",
|
|
56
|
+
)
|
|
57
|
+
return parser.parse_args(argv)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv: list[str] | None = None) -> int:
|
|
61
|
+
args = parse_args(argv)
|
|
62
|
+
config = ClientConfig(api_base_url=args.api)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
creds = credentials_from_args(args)
|
|
66
|
+
except RuntimeError as exc:
|
|
67
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
|
|
70
|
+
with HikConnectClient(config) as client:
|
|
71
|
+
try:
|
|
72
|
+
client.login(creds)
|
|
73
|
+
cameras = client.list_cameras()
|
|
74
|
+
|
|
75
|
+
if args.list:
|
|
76
|
+
if args.json:
|
|
77
|
+
dump_camera_list_json(cameras)
|
|
78
|
+
else:
|
|
79
|
+
print_camera_list(cameras)
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
if args.camera is None:
|
|
83
|
+
print("Specify a camera number or use --list.", file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
|
|
86
|
+
camera = resolve_camera(cameras, args.camera)
|
|
87
|
+
image_bytes = client.capture_snapshot(
|
|
88
|
+
camera,
|
|
89
|
+
validate_code=args.validate_code,
|
|
90
|
+
)
|
|
91
|
+
ensure_jpeg(image_bytes)
|
|
92
|
+
|
|
93
|
+
output_path = args.output or Path(f"camera-{args.camera}.jpg")
|
|
94
|
+
saved = save_image(image_bytes, output_path)
|
|
95
|
+
|
|
96
|
+
width, height = image_dimensions(image_bytes)
|
|
97
|
+
print(
|
|
98
|
+
f"Saved: {saved} ({camera.name} / ch {camera.channel_no}, "
|
|
99
|
+
f"{width}x{height})"
|
|
100
|
+
)
|
|
101
|
+
if (width, height) == CLOUD_CAPTURE_MAX:
|
|
102
|
+
print(
|
|
103
|
+
"Note: cloud snapshot is capped at "
|
|
104
|
+
f"{CLOUD_CAPTURE_MAX[0]}x{CLOUD_CAPTURE_MAX[1]} by Hik-Connect API. "
|
|
105
|
+
"Use hikcloudstream-stream for a live-stream frame.",
|
|
106
|
+
file=sys.stderr,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if args.show:
|
|
110
|
+
try:
|
|
111
|
+
show_image(image_bytes)
|
|
112
|
+
except Exception:
|
|
113
|
+
open_path(saved)
|
|
114
|
+
|
|
115
|
+
return 0
|
|
116
|
+
except httpx.HTTPError as exc:
|
|
117
|
+
print(f"HTTP error: {exc}", file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
except HikCloudStreamError as exc:
|
|
120
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Live stream CLI — open Hik-Connect camera streams and capture HD frames."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from hikcloudstream import HikConnectClient
|
|
16
|
+
from hikcloudstream.cli._common import (
|
|
17
|
+
add_auth_args,
|
|
18
|
+
credentials_from_args,
|
|
19
|
+
dump_camera_list_json,
|
|
20
|
+
ensure_jpeg,
|
|
21
|
+
image_dimensions,
|
|
22
|
+
open_path,
|
|
23
|
+
print_camera_list,
|
|
24
|
+
resolve_camera,
|
|
25
|
+
show_image,
|
|
26
|
+
)
|
|
27
|
+
from hikcloudstream.exceptions import HikCloudStreamError
|
|
28
|
+
from hikcloudstream.models import ClientConfig, StreamType
|
|
29
|
+
from hikcloudstream.stream import capture_live_snapshot, record_stream, require_ffmpeg
|
|
30
|
+
from hikcloudstream.stream.sinks.http import play_url, serve_stream_proxy
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_duration(value: str) -> float:
|
|
34
|
+
match = re.fullmatch(r"(\d+(?:\.\d+)?)(s|sec|secs|second|seconds|m|min|mins)?", value)
|
|
35
|
+
if not match:
|
|
36
|
+
raise argparse.ArgumentTypeError(
|
|
37
|
+
f"invalid duration {value!r}; use examples like 10s or 1m"
|
|
38
|
+
)
|
|
39
|
+
amount = float(match.group(1))
|
|
40
|
+
unit = match.group(2) or "s"
|
|
41
|
+
if unit.startswith("m"):
|
|
42
|
+
return amount * 60.0
|
|
43
|
+
return amount
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
description="Open live Hik-Connect camera streams and capture HD snapshots.",
|
|
49
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
50
|
+
epilog=(
|
|
51
|
+
"Examples:\n"
|
|
52
|
+
" hikcloudstream-stream user pass --list\n"
|
|
53
|
+
" hikcloudstream-stream user pass 1 --proxy\n"
|
|
54
|
+
" hikcloudstream-stream user pass 1 --proxy --show\n"
|
|
55
|
+
" hikcloudstream-stream user pass 1 -o frame-hd.jpg\n"
|
|
56
|
+
" hikcloudstream-stream user pass 1 --record clip.ts --duration 15s\n"
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
add_auth_args(parser)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"-o",
|
|
62
|
+
"--output",
|
|
63
|
+
type=Path,
|
|
64
|
+
help="Save a live-stream snapshot to this JPEG file",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--record",
|
|
68
|
+
type=Path,
|
|
69
|
+
metavar="FILE",
|
|
70
|
+
help="Record live stream to an MPEG-TS file",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--duration",
|
|
74
|
+
type=_parse_duration,
|
|
75
|
+
default=10.0,
|
|
76
|
+
help="Recording/snapshot warmup duration (default: 10s)",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--proxy",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="Serve the live stream over HTTP (blocks until Ctrl+C)",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--host",
|
|
85
|
+
default="127.0.0.1",
|
|
86
|
+
help="Proxy bind host (default: 127.0.0.1)",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--port",
|
|
90
|
+
type=int,
|
|
91
|
+
default=8558,
|
|
92
|
+
help="Proxy bind port (default: 8558)",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--show",
|
|
96
|
+
action="store_true",
|
|
97
|
+
help="With --proxy, open the stream in ffplay",
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--ffmpeg",
|
|
101
|
+
default="ffmpeg",
|
|
102
|
+
help="FFmpeg executable (default: ffmpeg)",
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--validate-code",
|
|
106
|
+
help="Device encryption code (only if the app asks for one)",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--main-stream",
|
|
110
|
+
action="store_true",
|
|
111
|
+
help="Use main stream (stream=1; higher resolution, not all cameras support it)",
|
|
112
|
+
)
|
|
113
|
+
return parser.parse_args(argv)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(argv: list[str] | None = None) -> int:
|
|
117
|
+
args = parse_args(argv)
|
|
118
|
+
|
|
119
|
+
if not args.list and args.camera is None:
|
|
120
|
+
print("Specify a camera number or use --list.", file=sys.stderr)
|
|
121
|
+
return 2
|
|
122
|
+
|
|
123
|
+
action_count = sum(1 for flag in (args.output, args.record, args.proxy) if flag)
|
|
124
|
+
if not args.list and action_count != 1:
|
|
125
|
+
print(
|
|
126
|
+
"Choose exactly one action: -o/--output, --record, or --proxy.",
|
|
127
|
+
file=sys.stderr,
|
|
128
|
+
)
|
|
129
|
+
return 2
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
creds = credentials_from_args(args)
|
|
133
|
+
except RuntimeError as exc:
|
|
134
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
135
|
+
return 2
|
|
136
|
+
|
|
137
|
+
config = ClientConfig(api_base_url=args.api)
|
|
138
|
+
stream_type = StreamType.MAIN if args.main_stream else StreamType.AUTO
|
|
139
|
+
|
|
140
|
+
with HikConnectClient(config) as client:
|
|
141
|
+
try:
|
|
142
|
+
client.login(creds)
|
|
143
|
+
cameras = client.list_cameras()
|
|
144
|
+
|
|
145
|
+
if args.list:
|
|
146
|
+
if args.json:
|
|
147
|
+
dump_camera_list_json(cameras)
|
|
148
|
+
else:
|
|
149
|
+
print_camera_list(cameras)
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
camera = resolve_camera(cameras, args.camera)
|
|
153
|
+
ffmpeg = require_ffmpeg(args.ffmpeg)
|
|
154
|
+
|
|
155
|
+
if args.output:
|
|
156
|
+
saved = capture_live_snapshot(
|
|
157
|
+
client,
|
|
158
|
+
camera,
|
|
159
|
+
args.output,
|
|
160
|
+
warmup_seconds=args.duration,
|
|
161
|
+
ffmpeg_path=ffmpeg,
|
|
162
|
+
validate_code=args.validate_code,
|
|
163
|
+
stream_type=stream_type,
|
|
164
|
+
)
|
|
165
|
+
image_bytes = saved.read_bytes()
|
|
166
|
+
ensure_jpeg(image_bytes)
|
|
167
|
+
width, height = image_dimensions(image_bytes)
|
|
168
|
+
print(
|
|
169
|
+
f"Saved: {saved} ({camera.name} / ch {camera.channel_no}, "
|
|
170
|
+
f"{width}x{height}, live stream)"
|
|
171
|
+
)
|
|
172
|
+
if args.show:
|
|
173
|
+
try:
|
|
174
|
+
show_image(image_bytes)
|
|
175
|
+
except Exception:
|
|
176
|
+
open_path(saved)
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
if args.record:
|
|
180
|
+
saved = record_stream(
|
|
181
|
+
client,
|
|
182
|
+
camera,
|
|
183
|
+
args.record,
|
|
184
|
+
duration_seconds=args.duration,
|
|
185
|
+
ffmpeg_path=ffmpeg,
|
|
186
|
+
validate_code=args.validate_code,
|
|
187
|
+
stream_type=stream_type,
|
|
188
|
+
)
|
|
189
|
+
print(
|
|
190
|
+
f"Recorded: {saved} ({camera.name} / ch {camera.channel_no}, "
|
|
191
|
+
f"{args.duration:.0f}s)"
|
|
192
|
+
)
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
stream_url = (
|
|
196
|
+
f"http://{args.host}:{args.port}/"
|
|
197
|
+
f"{camera.device_serial}-{camera.channel_no}.ts"
|
|
198
|
+
)
|
|
199
|
+
if args.show:
|
|
200
|
+
|
|
201
|
+
def _delayed_play() -> None:
|
|
202
|
+
time.sleep(0.8)
|
|
203
|
+
play_url(stream_url, player="ffplay")
|
|
204
|
+
|
|
205
|
+
threading.Thread(target=_delayed_play, daemon=True).start()
|
|
206
|
+
|
|
207
|
+
serve_stream_proxy(
|
|
208
|
+
client,
|
|
209
|
+
camera,
|
|
210
|
+
host=args.host,
|
|
211
|
+
port=args.port,
|
|
212
|
+
ffmpeg_path=ffmpeg,
|
|
213
|
+
validate_code=args.validate_code,
|
|
214
|
+
stream_type=stream_type,
|
|
215
|
+
)
|
|
216
|
+
return 0
|
|
217
|
+
except httpx.HTTPError as exc:
|
|
218
|
+
print(f"HTTP error: {exc}", file=sys.stderr)
|
|
219
|
+
return 1
|
|
220
|
+
except HikCloudStreamError as exc:
|
|
221
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
222
|
+
return 1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
raise SystemExit(main())
|