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.
@@ -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())