pyneolink 0.3.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.
- pyneolink/__init__.py +43 -0
- pyneolink/battery.py +136 -0
- pyneolink/camera.py +491 -0
- pyneolink/cli.py +419 -0
- pyneolink/config.py +96 -0
- pyneolink/core/__init__.py +5 -0
- pyneolink/core/bc.py +203 -0
- pyneolink/core/const/__init__.py +6 -0
- pyneolink/core/const/flags.py +75 -0
- pyneolink/core/const/msg.py +116 -0
- pyneolink/core/const/payloads.py +367 -0
- pyneolink/core/crypto.py +82 -0
- pyneolink/core/discovery.py +186 -0
- pyneolink/core/media.py +181 -0
- pyneolink/core/state.py +38 -0
- pyneolink/core/udp_transport.py +604 -0
- pyneolink/core/xmlutil.py +36 -0
- pyneolink/internal/__init__.py +1 -0
- pyneolink/internal/battery.py +99 -0
- pyneolink/internal/camera.py +63 -0
- pyneolink/internal/snapshot.py +31 -0
- pyneolink/internal/voice.py +488 -0
- pyneolink/motion.py +254 -0
- pyneolink/recorder.py +163 -0
- pyneolink/sd_card.py +1329 -0
- pyneolink/settings/__init__.py +14 -0
- pyneolink/settings/ir.py +107 -0
- pyneolink/settings/pir.py +104 -0
- pyneolink/stream_server.py +765 -0
- pyneolink/voice.py +227 -0
- pyneolink-0.3.0.dist-info/METADATA +377 -0
- pyneolink-0.3.0.dist-info/RECORD +36 -0
- pyneolink-0.3.0.dist-info/WHEEL +5 -0
- pyneolink-0.3.0.dist-info/entry_points.txt +2 -0
- pyneolink-0.3.0.dist-info/licenses/LICENSE +21 -0
- pyneolink-0.3.0.dist-info/top_level.txt +1 -0
pyneolink/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Python API for Reolink/Neolink cameras."""
|
|
2
|
+
|
|
3
|
+
from .camera import Camera
|
|
4
|
+
from .battery import Battery, BatteryInfo, BatteryInfoUpdates, parse_battery_xml
|
|
5
|
+
from .config import CameraConfig, Config, config_from_dict, load_config
|
|
6
|
+
from .core.const import EVENTS
|
|
7
|
+
from .motion import CameraEvent, CameraEvents, Motion, parse_motion_events
|
|
8
|
+
from .recorder import StreamRecorder
|
|
9
|
+
from .sd_card import DangerousSdCardOperation, DownloadSizeMismatch, SdCard, SdCardFile
|
|
10
|
+
from .settings import Ir, Pir, Settings
|
|
11
|
+
from .stream_server import StreamServer, serve_streams
|
|
12
|
+
from .voice import TalkConfig, Voice
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Camera",
|
|
16
|
+
"CameraConfig",
|
|
17
|
+
"CameraEvent",
|
|
18
|
+
"CameraEvents",
|
|
19
|
+
"Motion",
|
|
20
|
+
"Battery",
|
|
21
|
+
"BatteryInfo",
|
|
22
|
+
"BatteryInfoUpdates",
|
|
23
|
+
"Config",
|
|
24
|
+
"DangerousSdCardOperation",
|
|
25
|
+
"DownloadSizeMismatch",
|
|
26
|
+
"SdCard",
|
|
27
|
+
"SdCardFile",
|
|
28
|
+
"Ir",
|
|
29
|
+
"Pir",
|
|
30
|
+
"Settings",
|
|
31
|
+
"StreamServer",
|
|
32
|
+
"StreamRecorder",
|
|
33
|
+
"EVENTS",
|
|
34
|
+
"TalkConfig",
|
|
35
|
+
"Voice",
|
|
36
|
+
"config_from_dict",
|
|
37
|
+
"load_config",
|
|
38
|
+
"parse_motion_events",
|
|
39
|
+
"parse_battery_xml",
|
|
40
|
+
"serve_streams",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
|
43
|
+
__version__ = "0.3.0"
|
pyneolink/battery.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from .core.bc import ProtocolError
|
|
7
|
+
from .core.const import MSG, msg, payloads
|
|
8
|
+
from .internal.battery import normalize_mode, parse_battery_xml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Battery:
|
|
12
|
+
def __init__(self, camera) -> None:
|
|
13
|
+
self.camera = camera
|
|
14
|
+
|
|
15
|
+
def raw(self, *, mode: str = "reconnect") -> str | None:
|
|
16
|
+
return self._request(mode=mode).xml_text
|
|
17
|
+
|
|
18
|
+
def info(self, *, interval: float | None = None, count: int | None = None, mode: str = "reconnect"):
|
|
19
|
+
if interval is None:
|
|
20
|
+
return self.refresh(mode=mode)
|
|
21
|
+
return BatteryInfoUpdates(self, interval=interval, count=count, mode=mode)
|
|
22
|
+
|
|
23
|
+
def refresh(self, *, mode: str = "reconnect") -> dict[str, Any]:
|
|
24
|
+
reply = self._request(mode=mode)
|
|
25
|
+
if reply.header.response_code != 200:
|
|
26
|
+
raise ProtocolError(msg.Error.BatteryInfoFailed.format(response_code=reply.header.response_code))
|
|
27
|
+
return BatteryInfo(parse_battery_xml(reply.xml_root))
|
|
28
|
+
|
|
29
|
+
def watch(self, interval: float = 60.0, *, count: int | None = None, mode: str = "reconnect"):
|
|
30
|
+
with BatteryInfoUpdates(self, interval=interval, count=count, mode=mode) as updates:
|
|
31
|
+
yield from updates
|
|
32
|
+
|
|
33
|
+
def keepalive(self) -> str:
|
|
34
|
+
return self.camera.keepalive()
|
|
35
|
+
|
|
36
|
+
def _request(self, *, mode: str = "reconnect", retries: int = 1):
|
|
37
|
+
mode = normalize_mode(mode)
|
|
38
|
+
effective_online = mode == "online" or getattr(self.camera, "online_required", False)
|
|
39
|
+
if not effective_online:
|
|
40
|
+
self.camera.close()
|
|
41
|
+
channel_id = self.camera.config.channel_id
|
|
42
|
+
extension = payloads.extension.format(channel_id=channel_id)
|
|
43
|
+
try:
|
|
44
|
+
for attempt in range(retries + 1):
|
|
45
|
+
try:
|
|
46
|
+
return self.camera.command(MSG.BATTERY, extension=extension)
|
|
47
|
+
except (TimeoutError, EOFError, OSError):
|
|
48
|
+
if attempt >= retries:
|
|
49
|
+
raise
|
|
50
|
+
self.camera.reconnect()
|
|
51
|
+
raise TimeoutError(msg.Error.BatteryRequestFailed)
|
|
52
|
+
finally:
|
|
53
|
+
if not effective_online:
|
|
54
|
+
self.camera.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BatteryInfoUpdates:
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
battery: Battery,
|
|
61
|
+
*,
|
|
62
|
+
interval: float,
|
|
63
|
+
count: int | None = None,
|
|
64
|
+
mode: str = "reconnect",
|
|
65
|
+
keepalive_interval: float = 1.0,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.battery = battery
|
|
68
|
+
self.interval = max(interval, 0.0)
|
|
69
|
+
self.mode = normalize_mode(mode)
|
|
70
|
+
self.keepalive_interval = max(keepalive_interval, 0.1)
|
|
71
|
+
self.count = count
|
|
72
|
+
self.seen = 0
|
|
73
|
+
self.closed = False
|
|
74
|
+
self._online_lease = None
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> "BatteryInfoUpdates":
|
|
77
|
+
self._enter_online_mode()
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *exc: object) -> None:
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
def __iter__(self) -> "BatteryInfoUpdates":
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def __next__(self) -> dict[str, Any]:
|
|
87
|
+
if self.closed or (self.count is not None and self.seen >= self.count):
|
|
88
|
+
raise StopIteration
|
|
89
|
+
self._enter_online_mode()
|
|
90
|
+
if self.seen:
|
|
91
|
+
self._wait()
|
|
92
|
+
self.seen += 1
|
|
93
|
+
return self.battery.refresh(mode=self.mode)
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
lease = getattr(self, "_online_lease", None)
|
|
97
|
+
if lease is not None:
|
|
98
|
+
lease.__exit__(None, None, None)
|
|
99
|
+
self._online_lease = None
|
|
100
|
+
self.closed = True
|
|
101
|
+
|
|
102
|
+
def _enter_online_mode(self) -> None:
|
|
103
|
+
if self.mode != "online" or getattr(self, "_online_lease", None) is not None:
|
|
104
|
+
return
|
|
105
|
+
require_online = getattr(self.battery.camera, "require_online", None)
|
|
106
|
+
if require_online is None:
|
|
107
|
+
return
|
|
108
|
+
self._online_lease = require_online()
|
|
109
|
+
self._online_lease.__enter__()
|
|
110
|
+
|
|
111
|
+
def _wait(self) -> None:
|
|
112
|
+
if self.mode == "reconnect" and not getattr(self.battery.camera, "online_required", False):
|
|
113
|
+
time.sleep(self.interval)
|
|
114
|
+
return
|
|
115
|
+
deadline = time.monotonic() + self.interval
|
|
116
|
+
while not self.closed:
|
|
117
|
+
remaining = deadline - time.monotonic()
|
|
118
|
+
if remaining <= 0:
|
|
119
|
+
return
|
|
120
|
+
sleep_for = min(remaining, self.keepalive_interval)
|
|
121
|
+
time.sleep(sleep_for)
|
|
122
|
+
if not self.closed:
|
|
123
|
+
try:
|
|
124
|
+
self.battery.keepalive()
|
|
125
|
+
except (TimeoutError, EOFError, OSError):
|
|
126
|
+
self.battery.camera.reconnect()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class BatteryInfo(dict):
|
|
130
|
+
def __enter__(self) -> "BatteryInfo":
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
def __exit__(self, *exc: object) -> None:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
pyneolink/camera.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
from contextlib import AbstractContextManager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import CameraConfig
|
|
9
|
+
from .core.bc import (
|
|
10
|
+
ProtocolError,
|
|
11
|
+
encode_legacy_login,
|
|
12
|
+
encode_modern,
|
|
13
|
+
find_text,
|
|
14
|
+
recv_message,
|
|
15
|
+
)
|
|
16
|
+
from .core.const import MSG, MSG_CLASS, msg, payloads
|
|
17
|
+
from .battery import Battery
|
|
18
|
+
from .core.crypto import Cipher, make_aes_key, md5_hex
|
|
19
|
+
from .core.discovery import local_discover, remote_uid_lookup
|
|
20
|
+
from .core.state import ConnectionState
|
|
21
|
+
from .core.udp_transport import UdpBcConnection, connect_local_direct, connect_relay
|
|
22
|
+
from .motion import Motion
|
|
23
|
+
from .core.xmlutil import xml_to_dict
|
|
24
|
+
from .internal.camera import CameraOnlineLease, redact_sensitive, split_address, stream_params
|
|
25
|
+
from .internal.snapshot import parse_snapshot_info, snapshot_output_path
|
|
26
|
+
from .recorder import StreamRecorder
|
|
27
|
+
from .sd_card import SdCard
|
|
28
|
+
from .settings import Settings
|
|
29
|
+
from .voice import Voice
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Camera(AbstractContextManager["Camera"]):
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
config: CameraConfig | None = None,
|
|
36
|
+
*,
|
|
37
|
+
uuid: str | None = None,
|
|
38
|
+
uid: str | None = None,
|
|
39
|
+
username: str = "admin",
|
|
40
|
+
password: str = "123456",
|
|
41
|
+
name: str | None = None,
|
|
42
|
+
address: str | None = None,
|
|
43
|
+
cached_address: str | None = None,
|
|
44
|
+
discovery: str = "relay",
|
|
45
|
+
channel_id: int = 0,
|
|
46
|
+
stream: str = "both",
|
|
47
|
+
timeout: float = 10.0,
|
|
48
|
+
state_path: str | Path | None = ".pyneolink_state.json",
|
|
49
|
+
debug: bool = False,
|
|
50
|
+
) -> None:
|
|
51
|
+
if config is None:
|
|
52
|
+
camera_uid = uid or uuid
|
|
53
|
+
config = CameraConfig(
|
|
54
|
+
name=name or camera_uid or address or "camera",
|
|
55
|
+
username=username,
|
|
56
|
+
password=password,
|
|
57
|
+
address=address,
|
|
58
|
+
uid=camera_uid,
|
|
59
|
+
discovery=discovery,
|
|
60
|
+
channel_id=channel_id,
|
|
61
|
+
stream=stream,
|
|
62
|
+
cached_address=cached_address,
|
|
63
|
+
)
|
|
64
|
+
self.config = config
|
|
65
|
+
self.timeout = timeout
|
|
66
|
+
self.sock: socket.socket | UdpBcConnection | None = None
|
|
67
|
+
self.cipher = Cipher("bc")
|
|
68
|
+
self.msg_num = 0
|
|
69
|
+
self.binary_msg_nums: set[int] = set()
|
|
70
|
+
self.state = ConnectionState(state_path) if state_path else None
|
|
71
|
+
self.connected_address: tuple[str, int] | None = None
|
|
72
|
+
self.login_xml = ""
|
|
73
|
+
self.debug = debug
|
|
74
|
+
self._online_required = 0
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> "Camera":
|
|
77
|
+
self.connect()
|
|
78
|
+
self.login()
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def __exit__(self, *exc: object) -> None:
|
|
82
|
+
self.close()
|
|
83
|
+
|
|
84
|
+
def connect(self) -> None:
|
|
85
|
+
if (
|
|
86
|
+
self.config.uid
|
|
87
|
+
and not self.config.address
|
|
88
|
+
and not self.config.cached_address
|
|
89
|
+
and self.config.discovery in ("local", "remote", "map", "relay")
|
|
90
|
+
):
|
|
91
|
+
try:
|
|
92
|
+
probe_timeout = max(self.timeout, 8.0) if self.config.discovery == "local" else min(self.timeout, 2.0)
|
|
93
|
+
self.sock = connect_local_direct(self.config.uid, timeout=probe_timeout, debug=self.debug)
|
|
94
|
+
self.connected_address = self.sock.addr
|
|
95
|
+
if self.state:
|
|
96
|
+
self.state.update_address(
|
|
97
|
+
self.config.name,
|
|
98
|
+
f"{self.sock.addr[0]}:{self.sock.addr[1]}",
|
|
99
|
+
uid=self.config.uid,
|
|
100
|
+
transport="udp-local",
|
|
101
|
+
)
|
|
102
|
+
return
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
if self.debug:
|
|
105
|
+
print(msg.Log.LocalUdpP2pFailed.format(exc_type=type(exc).__name__, exc=exc))
|
|
106
|
+
if self.config.discovery == "local":
|
|
107
|
+
raise
|
|
108
|
+
resolved = self._resolve_address()
|
|
109
|
+
if len(resolved) == 3 and resolved[2] == "udp-relay":
|
|
110
|
+
if not self.config.uid:
|
|
111
|
+
raise ValueError(msg.Error.UdpRelayRequiresUid)
|
|
112
|
+
self.sock = connect_relay(self.config.uid, timeout=max(self.timeout, 20.0), debug=self.debug)
|
|
113
|
+
self.connected_address = self.sock.addr
|
|
114
|
+
if self.state:
|
|
115
|
+
self.state.update_address(self.config.name, f"{self.sock.addr[0]}:{self.sock.addr[1]}", uid=self.config.uid, transport="udp-relay")
|
|
116
|
+
return
|
|
117
|
+
host, port = resolved[:2]
|
|
118
|
+
self.sock = socket.create_connection((host, port), timeout=self.timeout)
|
|
119
|
+
self.connected_address = (host, port)
|
|
120
|
+
if self.state:
|
|
121
|
+
self.state.update_address(self.config.name, f"{host}:{port}", uid=self.config.uid, transport="tcp")
|
|
122
|
+
|
|
123
|
+
def close(self) -> None:
|
|
124
|
+
if self.sock:
|
|
125
|
+
self.sock.close()
|
|
126
|
+
self.sock = None
|
|
127
|
+
self.login_xml = ""
|
|
128
|
+
|
|
129
|
+
def reconnect(self) -> None:
|
|
130
|
+
self.close()
|
|
131
|
+
self.connect()
|
|
132
|
+
self.login()
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def online_required(self) -> bool:
|
|
136
|
+
return self._online_required > 0
|
|
137
|
+
|
|
138
|
+
def require_online(self):
|
|
139
|
+
return CameraOnlineLease(self)
|
|
140
|
+
|
|
141
|
+
def keepalive(self, *, timeout: float = 0.05) -> str:
|
|
142
|
+
self.ensure_connected()
|
|
143
|
+
if hasattr(self.sock, "maintain"):
|
|
144
|
+
self.sock.maintain()
|
|
145
|
+
try:
|
|
146
|
+
msg = self._recv(timeout=timeout)
|
|
147
|
+
except TimeoutError:
|
|
148
|
+
return "timeout"
|
|
149
|
+
return f"msg_id={msg.header.msg_id} msg_num={msg.header.msg_num} response={msg.header.response_code}"
|
|
150
|
+
|
|
151
|
+
def login(self, max_encryption: str = "aes") -> str:
|
|
152
|
+
if self.sock is None:
|
|
153
|
+
self.connect()
|
|
154
|
+
if self.login_xml:
|
|
155
|
+
return self.login_xml
|
|
156
|
+
msg_num = self._next_msg()
|
|
157
|
+
self._send(encode_legacy_login(msg_num, max_encryption=max_encryption, channel_id=self.config.channel_id))
|
|
158
|
+
reply = self._recv()
|
|
159
|
+
nonce = find_text(reply.xml_root, "nonce")
|
|
160
|
+
if not nonce:
|
|
161
|
+
raise ProtocolError(msg.Error.LoginNonce)
|
|
162
|
+
low = reply.header.response_code & 0xFF
|
|
163
|
+
if low == 0:
|
|
164
|
+
self.cipher = Cipher("none")
|
|
165
|
+
elif low == 1:
|
|
166
|
+
self.cipher = Cipher("bc")
|
|
167
|
+
elif low in (2, 3, 0x12):
|
|
168
|
+
self.cipher = Cipher("aes", make_aes_key(nonce, self.config.password), full_media=(low == 0x12))
|
|
169
|
+
username = md5_hex(self.config.username + nonce)
|
|
170
|
+
password = md5_hex((self.config.password or "") + nonce)
|
|
171
|
+
payload = payloads.login.format(username=username, password=password)
|
|
172
|
+
self._send(encode_modern(MSG.LOGIN, msg_num, payload, channel_id=self.config.channel_id, cipher=self.cipher))
|
|
173
|
+
modern = self._recv()
|
|
174
|
+
if modern.header.response_code != 200:
|
|
175
|
+
raise ProtocolError(msg.Error.LoginFailed.format(response_code=modern.header.response_code))
|
|
176
|
+
self.login_xml = modern.xml_text or ""
|
|
177
|
+
return self.login_xml
|
|
178
|
+
|
|
179
|
+
def info(self, *, include_sensitive: bool = False) -> dict:
|
|
180
|
+
self.ensure_connected()
|
|
181
|
+
info = xml_to_dict(self.login_xml)
|
|
182
|
+
if not include_sensitive:
|
|
183
|
+
redact_sensitive(info)
|
|
184
|
+
return {
|
|
185
|
+
"name": self.config.name,
|
|
186
|
+
"uid": self.config.uid or self.get_uid(),
|
|
187
|
+
"connected_address": f"{self.connected_address[0]}:{self.connected_address[1]}" if self.connected_address else None,
|
|
188
|
+
"device": info,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
def sd_card(self) -> SdCard:
|
|
192
|
+
return SdCard(self)
|
|
193
|
+
|
|
194
|
+
def get_uid(self) -> str | None:
|
|
195
|
+
self.ensure_connected()
|
|
196
|
+
reply = self.command(MSG.UID)
|
|
197
|
+
return find_text(reply.xml_root, "uid") or find_text(reply.xml_root, "UID")
|
|
198
|
+
|
|
199
|
+
def reboot(self) -> None:
|
|
200
|
+
self.ensure_connected()
|
|
201
|
+
self.command(MSG.REBOOT)
|
|
202
|
+
|
|
203
|
+
def led(self, value: str | None = None) -> dict:
|
|
204
|
+
self.ensure_connected()
|
|
205
|
+
if value is None:
|
|
206
|
+
return self.settings().ir.status()
|
|
207
|
+
normalized = value.lower()
|
|
208
|
+
if normalized in ("1", "on", "true", "open"):
|
|
209
|
+
return self.settings().ir.on()
|
|
210
|
+
if normalized in ("0", "off", "false", "close"):
|
|
211
|
+
return self.settings().ir.off()
|
|
212
|
+
if normalized == "auto":
|
|
213
|
+
return self.settings().ir.auto()
|
|
214
|
+
raise ValueError(msg.Error.IrModeValue)
|
|
215
|
+
|
|
216
|
+
def snapshot(self, *, out: str | Path | None = None, stream_type: str = "main") -> bytes | Path:
|
|
217
|
+
self.ensure_connected()
|
|
218
|
+
msg_num = self.send(
|
|
219
|
+
MSG.SNAP,
|
|
220
|
+
payloads.snapshot.format(channel_id=self.config.channel_id, stream_type=stream_type),
|
|
221
|
+
extension=payloads.extension.format(channel_id=self.config.channel_id),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
info = self._recv_matching(MSG.SNAP, msg_num)
|
|
225
|
+
if info.header.response_code != 200:
|
|
226
|
+
raise ProtocolError(msg.Error.SnapshotInfoFailed.format(response_code=info.header.response_code))
|
|
227
|
+
|
|
228
|
+
file_name, expected_size = parse_snapshot_info(info.xml_root)
|
|
229
|
+
data = bytearray()
|
|
230
|
+
deadline = time.monotonic() + self.timeout
|
|
231
|
+
while True:
|
|
232
|
+
reply = self._recv()
|
|
233
|
+
if reply.header.msg_id != MSG.SNAP:
|
|
234
|
+
if time.monotonic() > deadline:
|
|
235
|
+
raise TimeoutError(msg.Error.TimedOutResponse.format(msg_id=MSG.SNAP, msg_num=msg_num))
|
|
236
|
+
continue
|
|
237
|
+
if reply.payload:
|
|
238
|
+
data.extend(reply.payload)
|
|
239
|
+
if reply.header.response_code == 201:
|
|
240
|
+
break
|
|
241
|
+
if reply.header.response_code != 200:
|
|
242
|
+
raise ProtocolError(msg.Error.SnapshotDataFailed.format(response_code=reply.header.response_code))
|
|
243
|
+
deadline = time.monotonic() + self.timeout
|
|
244
|
+
|
|
245
|
+
if expected_size is not None and len(data) != expected_size:
|
|
246
|
+
raise ProtocolError(msg.Error.SnapshotSizeMismatch.format(actual_size=len(data), expected_size=expected_size))
|
|
247
|
+
|
|
248
|
+
image = bytes(data)
|
|
249
|
+
if out is None:
|
|
250
|
+
return image
|
|
251
|
+
path = snapshot_output_path(out, file_name)
|
|
252
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
path.write_bytes(image)
|
|
254
|
+
return path
|
|
255
|
+
|
|
256
|
+
def record(
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
out: str | Path,
|
|
260
|
+
duration: float | None = None,
|
|
261
|
+
stream: str = "mainStream",
|
|
262
|
+
) -> StreamRecorder | Path:
|
|
263
|
+
self.ensure_connected()
|
|
264
|
+
recorder = StreamRecorder(self, out=out, stream=stream, duration=duration).start()
|
|
265
|
+
if duration is not None:
|
|
266
|
+
return recorder.wait()
|
|
267
|
+
return recorder
|
|
268
|
+
|
|
269
|
+
def battery(self) -> Battery:
|
|
270
|
+
return Battery(self)
|
|
271
|
+
|
|
272
|
+
def motion(self, *, channel_id: int | None = None) -> Motion:
|
|
273
|
+
return Motion(self, channel_id=channel_id)
|
|
274
|
+
|
|
275
|
+
def motion_status(self, *, timeout: float = 3.0, channel_id: int | None = None) -> dict:
|
|
276
|
+
return self.motion(channel_id=channel_id).status(timeout=timeout)
|
|
277
|
+
|
|
278
|
+
def voice(self) -> Voice:
|
|
279
|
+
return Voice(self)
|
|
280
|
+
|
|
281
|
+
def settings(self) -> Settings:
|
|
282
|
+
return Settings(self)
|
|
283
|
+
|
|
284
|
+
def battery_xml(self, *, mode: str = "reconnect") -> str | None:
|
|
285
|
+
return self.battery().raw(mode=mode)
|
|
286
|
+
|
|
287
|
+
def battery_info(self, *, mode: str = "reconnect") -> dict:
|
|
288
|
+
return self.battery().info(mode=mode)
|
|
289
|
+
|
|
290
|
+
def watch_battery(self, interval: float = 60.0, *, count: int | None = None, mode: str = "reconnect"):
|
|
291
|
+
yield from self.battery().watch(interval=interval, count=count, mode=mode)
|
|
292
|
+
|
|
293
|
+
def command(self, msg_id: int, payload: bytes = b"", *, extension: bytes = b""):
|
|
294
|
+
self.ensure_connected()
|
|
295
|
+
msg_num = self.send(msg_id, payload, extension=extension)
|
|
296
|
+
return self._recv_matching(msg_id, msg_num)
|
|
297
|
+
|
|
298
|
+
def _recv_matching(self, msg_id: int, msg_num: int):
|
|
299
|
+
deadline = time.monotonic() + self.timeout
|
|
300
|
+
while True:
|
|
301
|
+
reply_msg = self._recv()
|
|
302
|
+
if reply_msg.header.msg_num == msg_num:
|
|
303
|
+
if hasattr(self.sock, "discard_sent"):
|
|
304
|
+
self.sock.discard_sent()
|
|
305
|
+
return reply_msg
|
|
306
|
+
if self.debug:
|
|
307
|
+
print(
|
|
308
|
+
msg.Log.IgnoringUnmatchedMessage.format(
|
|
309
|
+
msg_id=reply_msg.header.msg_id,
|
|
310
|
+
msg_num=reply_msg.header.msg_num,
|
|
311
|
+
expected_msg_num=msg_num,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
if time.monotonic() > deadline:
|
|
315
|
+
raise TimeoutError(msg.Error.TimedOutResponse.format(msg_id=msg_id, msg_num=msg_num))
|
|
316
|
+
|
|
317
|
+
def send(
|
|
318
|
+
self,
|
|
319
|
+
msg_id: int,
|
|
320
|
+
payload: bytes = b"",
|
|
321
|
+
*,
|
|
322
|
+
extension: bytes = b"",
|
|
323
|
+
binary_reply: bool = False,
|
|
324
|
+
msg_class: int = MSG_CLASS.MODERN,
|
|
325
|
+
channel_id: int | None = None,
|
|
326
|
+
msg_num: int | None = None,
|
|
327
|
+
stream_type: int = 0,
|
|
328
|
+
) -> int:
|
|
329
|
+
self.ensure_connected()
|
|
330
|
+
sent_msg_num = self._next_msg() if msg_num is None else msg_num
|
|
331
|
+
if binary_reply:
|
|
332
|
+
self.binary_msg_nums.add(sent_msg_num)
|
|
333
|
+
self._send(
|
|
334
|
+
encode_modern(
|
|
335
|
+
msg_id,
|
|
336
|
+
sent_msg_num,
|
|
337
|
+
payload,
|
|
338
|
+
extension=extension,
|
|
339
|
+
channel_id=self.config.channel_id if channel_id is None else channel_id,
|
|
340
|
+
msg_class=msg_class,
|
|
341
|
+
stream_type=stream_type,
|
|
342
|
+
cipher=self.cipher,
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
return sent_msg_num
|
|
346
|
+
|
|
347
|
+
def start_stream(self, stream: str = "mainStream"):
|
|
348
|
+
self.ensure_connected()
|
|
349
|
+
msg_num = self._next_msg()
|
|
350
|
+
stream_name, stream_code, handle = stream_params(stream)
|
|
351
|
+
payload = payloads.preview_start.format(channel_id=self.config.channel_id, handle=handle, stream_type=stream_name)
|
|
352
|
+
self._send(
|
|
353
|
+
encode_modern(
|
|
354
|
+
MSG.VIDEO,
|
|
355
|
+
msg_num,
|
|
356
|
+
payload,
|
|
357
|
+
channel_id=self.config.channel_id,
|
|
358
|
+
stream_type=stream_code,
|
|
359
|
+
cipher=self.cipher,
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
deadline = time.monotonic() + self.timeout
|
|
363
|
+
while True:
|
|
364
|
+
reply_msg = self._recv()
|
|
365
|
+
if reply_msg.header.msg_id == MSG.VIDEO and reply_msg.header.msg_num == msg_num:
|
|
366
|
+
if reply_msg.header.response_code != 200:
|
|
367
|
+
raise ProtocolError(msg.Error.StreamStartFailed.format(response_code=reply_msg.header.response_code))
|
|
368
|
+
self.binary_msg_nums.add(msg_num)
|
|
369
|
+
return msg_num
|
|
370
|
+
if time.monotonic() > deadline:
|
|
371
|
+
raise TimeoutError(msg.Error.StreamStartTimeout.format(msg_num=msg_num))
|
|
372
|
+
|
|
373
|
+
def stop_stream(self, stream: str = "mainStream", msg_num: int | None = None) -> None:
|
|
374
|
+
self.ensure_connected()
|
|
375
|
+
_stream_name, stream_code, handle = stream_params(stream)
|
|
376
|
+
sent_msg_num = self._next_msg() if msg_num is None else msg_num
|
|
377
|
+
payload = payloads.preview_stop.format(channel_id=self.config.channel_id, handle=handle)
|
|
378
|
+
self.binary_msg_nums.discard(sent_msg_num)
|
|
379
|
+
self._send(
|
|
380
|
+
encode_modern(
|
|
381
|
+
MSG.VIDEO_STOP,
|
|
382
|
+
sent_msg_num,
|
|
383
|
+
payload,
|
|
384
|
+
channel_id=self.config.channel_id,
|
|
385
|
+
stream_type=stream_code,
|
|
386
|
+
cipher=self.cipher,
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
deadline = time.monotonic() + min(self.timeout, 2.0)
|
|
390
|
+
while time.monotonic() <= deadline:
|
|
391
|
+
try:
|
|
392
|
+
reply_msg = self._recv(timeout=0.5)
|
|
393
|
+
except TimeoutError:
|
|
394
|
+
return
|
|
395
|
+
if reply_msg.header.msg_id == MSG.VIDEO_STOP and reply_msg.header.msg_num == sent_msg_num:
|
|
396
|
+
if reply_msg.header.response_code not in (0, 200):
|
|
397
|
+
if self.debug:
|
|
398
|
+
print(msg.Log.StreamStopReturned.format(response_code=reply_msg.header.response_code))
|
|
399
|
+
return
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
def read_stream_payloads(self, stream: str = "mainStream"):
|
|
403
|
+
with self.require_online():
|
|
404
|
+
msg_num = self.start_stream(stream)
|
|
405
|
+
next_keepalive_at = time.monotonic() + 0.75
|
|
406
|
+
try:
|
|
407
|
+
while True:
|
|
408
|
+
now = time.monotonic()
|
|
409
|
+
if now >= next_keepalive_at:
|
|
410
|
+
self.send(MSG.UDP_KEEPALIVE, channel_id=0, msg_num=0)
|
|
411
|
+
next_keepalive_at = now + 0.75
|
|
412
|
+
try:
|
|
413
|
+
msg = self._recv(timeout=1.0)
|
|
414
|
+
except TimeoutError:
|
|
415
|
+
continue
|
|
416
|
+
if msg.header.msg_id == MSG.VIDEO and msg.header.msg_num == msg_num and msg.payload:
|
|
417
|
+
yield msg.payload
|
|
418
|
+
finally:
|
|
419
|
+
try:
|
|
420
|
+
self.stop_stream(stream, msg_num)
|
|
421
|
+
except Exception as exc:
|
|
422
|
+
if self.debug:
|
|
423
|
+
print(msg.Log.StreamStopCloseFailed.format(exc_type=type(exc).__name__, exc=exc))
|
|
424
|
+
|
|
425
|
+
def _resolve_address(self) -> tuple[str, int] | tuple[str, int, str]:
|
|
426
|
+
if self.config.address:
|
|
427
|
+
return split_address(self.config.address)
|
|
428
|
+
if self.config.cached_address:
|
|
429
|
+
return split_address(self.config.cached_address)
|
|
430
|
+
if self.state:
|
|
431
|
+
cached = self.state.get_address(self.config.name, transport="tcp")
|
|
432
|
+
if cached:
|
|
433
|
+
return split_address(cached)
|
|
434
|
+
if self.config.uid:
|
|
435
|
+
if self.config.discovery in ("relay", "cellular"):
|
|
436
|
+
return "", 0, "udp-relay"
|
|
437
|
+
hits = []
|
|
438
|
+
if self.config.discovery in ("local", "remote", "map", "relay"):
|
|
439
|
+
hits.extend(local_discover(self.config.uid, timeout=min(self.timeout, 15.0)))
|
|
440
|
+
if not hits and self.config.discovery in ("remote", "map", "relay", "cellular"):
|
|
441
|
+
hits.extend(remote_uid_lookup(self.config.uid, timeout=min(self.timeout, 15.0)))
|
|
442
|
+
if hits:
|
|
443
|
+
tcp_hits = [hit for hit in hits if hit.transport == "tcp"]
|
|
444
|
+
if tcp_hits:
|
|
445
|
+
host, port = tcp_hits[0].address
|
|
446
|
+
return host, port if port else 9000
|
|
447
|
+
return "", 0, "udp-relay"
|
|
448
|
+
raise ValueError(msg.Error.CameraAddressRequired)
|
|
449
|
+
|
|
450
|
+
def ensure_connected(self) -> None:
|
|
451
|
+
if self.sock is None:
|
|
452
|
+
self.connect()
|
|
453
|
+
if not self.login_xml:
|
|
454
|
+
self.login()
|
|
455
|
+
|
|
456
|
+
def _next_msg(self) -> int:
|
|
457
|
+
self.msg_num = (self.msg_num + 1) & 0xFFFF
|
|
458
|
+
return self.msg_num or self._next_msg()
|
|
459
|
+
|
|
460
|
+
def _send(self, data: bytes) -> None:
|
|
461
|
+
if self.sock is None:
|
|
462
|
+
raise RuntimeError(msg.Error.CameraNotConnected)
|
|
463
|
+
self.sock.sendall(data)
|
|
464
|
+
|
|
465
|
+
def _recv(self, timeout: float | None = None):
|
|
466
|
+
if self.sock is None:
|
|
467
|
+
raise RuntimeError(msg.Error.CameraNotConnected)
|
|
468
|
+
msg = recv_message(self.sock, self.cipher, timeout=self.timeout if timeout is None else timeout, binary_msg_nums=self.binary_msg_nums)
|
|
469
|
+
if msg.header.msg_id == MSG.UDP_KEEPALIVE:
|
|
470
|
+
self._reply_keepalive(msg)
|
|
471
|
+
return msg
|
|
472
|
+
|
|
473
|
+
def _reply_keepalive(self, keepalive_msg) -> None:
|
|
474
|
+
if self.sock is None:
|
|
475
|
+
return
|
|
476
|
+
try:
|
|
477
|
+
data = encode_modern(
|
|
478
|
+
MSG.UDP_KEEPALIVE,
|
|
479
|
+
keepalive_msg.header.msg_num,
|
|
480
|
+
channel_id=keepalive_msg.header.channel_id,
|
|
481
|
+
stream_type=keepalive_msg.header.stream_type,
|
|
482
|
+
response_code=200,
|
|
483
|
+
cipher=self.cipher,
|
|
484
|
+
)
|
|
485
|
+
if hasattr(self.sock, "send_untracked"):
|
|
486
|
+
self.sock.send_untracked(data)
|
|
487
|
+
else:
|
|
488
|
+
self._send(data)
|
|
489
|
+
except Exception as exc:
|
|
490
|
+
if self.debug:
|
|
491
|
+
print(msg.Log.StreamKeepaliveReplyFailed.format(exc_type=type(exc).__name__, exc=exc))
|