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 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))