python-aidot-cameras 0.7.8__tar.gz → 0.7.9__tar.gz
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.
- {python_aidot_cameras-0.7.8/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.9}/PKG-INFO +1 -1
- python_aidot_cameras-0.7.9/pyproject.toml +74 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/aes_utils.py +2 -2
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/camera/__init__.py +1 -1
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/camera/client.py +99 -85
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/camera/lan_control.py +5 -5
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/client.py +3 -3
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/const.py +1 -1
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/device_client.py +20 -15
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/discover.py +0 -1
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/models/device_client_model.py +2 -2
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_post_merge_hardening.py +1 -2
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_stream_cap.py +1 -1
- python_aidot_cameras-0.7.8/pyproject.toml +0 -41
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/LICENSE +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/README.md +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/setup.cfg +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/__init__.py +5 -5
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/g711.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/models/__init__.py +6 -6
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/python_aidot_cameras.egg-info/SOURCES.txt +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_token_refresh.py +0 -0
{python_aidot_cameras-0.7.8/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.9}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.9
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-aidot-cameras"
|
|
7
|
+
version = "0.7.9"
|
|
8
|
+
description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "cbrightly", email = "chris.brightly@gmail.com" }]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"aiohttp",
|
|
20
|
+
"paho-mqtt>=2.0",
|
|
21
|
+
"cryptography",
|
|
22
|
+
"pycryptodome",
|
|
23
|
+
"dacite",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
webrtc = [
|
|
28
|
+
"aiortc>=1.9.0",
|
|
29
|
+
"av",
|
|
30
|
+
"pylibsrtp",
|
|
31
|
+
"pyopenssl",
|
|
32
|
+
"numpy",
|
|
33
|
+
"Pillow",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/cbrightly/python-AiDot"
|
|
38
|
+
"Issue Tracker" = "https://github.com/cbrightly/python-AiDot/issues"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
target-version = "py311"
|
|
45
|
+
line-length = 88
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
# High-signal rules only; opinionated style (quotes, trailing commas, missing
|
|
49
|
+
# annotations/docstrings, magic values) is intentionally left to the formatter.
|
|
50
|
+
select = ["F", "E4", "E7", "E9", "W", "B", "ASYNC", "RUF", "UP", "SIM", "PIE", "RET", "PERF"]
|
|
51
|
+
ignore = [
|
|
52
|
+
"E501", # line length: formatter's job
|
|
53
|
+
"E701", "E741", # multiple-statements / ambiguous name: cosmetic
|
|
54
|
+
"UP035", "UP006", "UP007", "UP045", "UP037", "UP031", # typing/format style (keep broad compat)
|
|
55
|
+
"E702", # multiple-statements (semicolon): cosmetic
|
|
56
|
+
"SIM102", "SIM103", "SIM105", "SIM108", "SIM117", # collapsible/ternary/suppress/nested: stylistic
|
|
57
|
+
"ASYNC109", "ASYNC240", # timeout-param design; os.path.join is non-blocking
|
|
58
|
+
"RET504", "RET505", "RET506", # return-style: stylistic
|
|
59
|
+
"PERF203", "PERF401", # try/except in loop, manual comprehension: stylistic
|
|
60
|
+
"PIE810", # multiple startswith/endswith: stylistic
|
|
61
|
+
"RUF001", "RUF003", "RUF059", # ambiguous-unicode (intentional ×), unused-unpacked: cosmetic
|
|
62
|
+
"B904", # raise-without-from: traceback-chaining style (not enforced)
|
|
63
|
+
"B008",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.per-file-ignores]
|
|
67
|
+
"tests/**" = ["B", "RUF", "SIM", "PERF", "ASYNC", "F811", "PIE", "E731", "E702"]
|
|
68
|
+
# Re-export modules: imports are intentional public re-exports / back-compat shims.
|
|
69
|
+
"__init__.py" = ["F401"]
|
|
70
|
+
# device_client.py re-exports from camera.client mid-file (avoids a circular import).
|
|
71
|
+
"**/device_client.py" = ["F401", "E402"]
|
|
72
|
+
# Inherited ffmpeg streaming engine is process-based (Popen / subprocess poll /
|
|
73
|
+
# SDP file write); a full non-blocking refactor needs on-device testing.
|
|
74
|
+
"src/aidot/camera/client.py" = ["ASYNC110", "ASYNC220", "ASYNC221", "ASYNC230"]
|
|
@@ -31,11 +31,11 @@ def aes_decrypt(ciphertext, key):
|
|
|
31
31
|
|
|
32
32
|
def aes_decrypt_to_json(ciphertext: bytes, key: Optional[bytes] = None) -> dict[str, Any]:
|
|
33
33
|
"""Decrypt AES encrypted data and parse to JSON.
|
|
34
|
-
|
|
34
|
+
|
|
35
35
|
Args:
|
|
36
36
|
ciphertext: AES encrypted data
|
|
37
37
|
key: AES key (optional, if None, assumes data is already decrypted)
|
|
38
|
-
|
|
38
|
+
|
|
39
39
|
Returns:
|
|
40
40
|
Parsed JSON dict
|
|
41
41
|
"""
|
|
@@ -5,47 +5,40 @@ import json
|
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
7
|
import random
|
|
8
|
-
import socket
|
|
9
8
|
import struct
|
|
10
9
|
import threading
|
|
11
10
|
import time
|
|
12
11
|
import asyncio
|
|
13
12
|
import zlib
|
|
14
13
|
from dataclasses import dataclass
|
|
15
|
-
from datetime import datetime
|
|
16
14
|
from typing import Any, Callable, List, Optional
|
|
17
15
|
|
|
18
|
-
from ..aes_utils import
|
|
16
|
+
from ..aes_utils import aes_ecb_encrypt_str_key, aes_ecb_decrypt_str_key
|
|
19
17
|
from ..exceptions import AidotCameraBusy
|
|
20
18
|
from ..login_const import APP_ID as _AIDOT_APP_ID
|
|
21
19
|
from ..const import (
|
|
22
|
-
CONF_AES_KEY,
|
|
23
|
-
CONF_ASCNUMBER,
|
|
24
|
-
CONF_ATTR,
|
|
25
20
|
CONF_CCT,
|
|
26
|
-
CONF_HARDWARE_VERSION,
|
|
27
|
-
CONF_ID,
|
|
28
|
-
CONF_IDENTITY,
|
|
29
|
-
CONF_MAC,
|
|
30
|
-
CONF_MAXVALUE,
|
|
31
|
-
CONF_MINVALUE,
|
|
32
|
-
CONF_MODEL_ID,
|
|
33
|
-
CONF_NAME,
|
|
34
21
|
CONF_ON_OFF,
|
|
35
22
|
CONF_DIMMING,
|
|
36
|
-
CONF_PASSWORD,
|
|
37
|
-
CONF_PAYLOAD,
|
|
38
23
|
CONF_PRODUCT,
|
|
39
24
|
CONF_PROPERTIES,
|
|
40
25
|
CONF_RGBW,
|
|
41
26
|
CONF_SERVICE_MODULES,
|
|
42
|
-
CONF_ACK,
|
|
43
|
-
CONF_CODE,
|
|
44
|
-
Identity,
|
|
45
27
|
)
|
|
46
28
|
|
|
47
29
|
_LOGGER = logging.getLogger(__name__)
|
|
48
30
|
|
|
31
|
+
# Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
|
|
32
|
+
# discarded task can be garbage-collected mid-flight. Discarded on completion.
|
|
33
|
+
_BG_TASKS: set = set()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _spawn_bg(coro):
|
|
37
|
+
_t = asyncio.ensure_future(coro)
|
|
38
|
+
_BG_TASKS.add(_t)
|
|
39
|
+
_t.add_done_callback(_BG_TASKS.discard)
|
|
40
|
+
return _t
|
|
41
|
+
|
|
49
42
|
|
|
50
43
|
# Runtime import: device_client defines these classes BEFORE importing this
|
|
51
44
|
# module (the package __init__ loads aidot.device_client first), so the
|
|
@@ -447,10 +440,11 @@ def _build_stun_binding_success_response(
|
|
|
447
440
|
valid fingerprinted STUN envelope. This helper emits:
|
|
448
441
|
XOR-MAPPED-ADDRESS + MESSAGE-INTEGRITY + FINGERPRINT
|
|
449
442
|
"""
|
|
450
|
-
import hmac as _hmac
|
|
443
|
+
import hmac as _hmac
|
|
444
|
+
import hashlib as _hashlib
|
|
451
445
|
|
|
452
446
|
ip_parts = [int(x) for x in mapped_ip.split(".")]
|
|
453
|
-
xip = bytes(a ^ b for a, b in zip(struct.pack("!4B", *ip_parts), magic_cookie))
|
|
447
|
+
xip = bytes(a ^ b for a, b in zip(struct.pack("!4B", *ip_parts), magic_cookie, strict=False))
|
|
454
448
|
xport = (mapped_port ^ 0x2112) & 0xFFFF
|
|
455
449
|
xma = b"\x00\x20\x00\x08\x00\x01" + struct.pack("!H", xport) + xip
|
|
456
450
|
|
|
@@ -797,7 +791,7 @@ class CloudPlaybackSession:
|
|
|
797
791
|
hdr, resp_payload = await asyncio.wait_for(
|
|
798
792
|
_read_frame(self._reader), timeout=10.0
|
|
799
793
|
)
|
|
800
|
-
except
|
|
794
|
+
except TimeoutError:
|
|
801
795
|
_LOGGER.error("Cloud playback: login response timed out")
|
|
802
796
|
return False
|
|
803
797
|
except Exception as exc:
|
|
@@ -861,7 +855,7 @@ class CloudPlaybackSession:
|
|
|
861
855
|
_read_frame(self._reader),
|
|
862
856
|
timeout=30.0,
|
|
863
857
|
)
|
|
864
|
-
except
|
|
858
|
+
except TimeoutError:
|
|
865
859
|
_LOGGER.warning("Cloud playback: receive timeout")
|
|
866
860
|
break
|
|
867
861
|
except asyncio.IncompleteReadError:
|
|
@@ -987,7 +981,6 @@ class TutkStreamSession:
|
|
|
987
981
|
None, self._start_sync)
|
|
988
982
|
|
|
989
983
|
def _start_sync(self) -> bool:
|
|
990
|
-
import ctypes
|
|
991
984
|
|
|
992
985
|
try:
|
|
993
986
|
iotc = ctypes.CDLL(self._iotc_lib_path)
|
|
@@ -1134,7 +1127,6 @@ class TutkStreamSession:
|
|
|
1134
1127
|
return True
|
|
1135
1128
|
|
|
1136
1129
|
def _recv_loop(self, av, iotc, FrameInfo) -> None:
|
|
1137
|
-
import ctypes
|
|
1138
1130
|
|
|
1139
1131
|
# avRecvFrameData2 signature (from AVAPIs.java / TUTK SDK):
|
|
1140
1132
|
# (nAVIndex, abFrameData, nFrameDataMaxSize,
|
|
@@ -1361,7 +1353,7 @@ class LiveStreamSession:
|
|
|
1361
1353
|
_read_frame(self._reader),
|
|
1362
1354
|
timeout=hb_interval * 2,
|
|
1363
1355
|
)
|
|
1364
|
-
except
|
|
1356
|
+
except TimeoutError:
|
|
1365
1357
|
_LOGGER.warning("LiveStreamSession: receive timeout -- reconnect?")
|
|
1366
1358
|
break
|
|
1367
1359
|
|
|
@@ -1748,7 +1740,9 @@ def _run_sdes_talk_pump(state: dict) -> None:
|
|
|
1748
1740
|
and exits when ``state['stop']`` is set. This is the validated spike pump
|
|
1749
1741
|
with a live provider in place of the test tone.
|
|
1750
1742
|
"""
|
|
1751
|
-
import time as _t
|
|
1743
|
+
import time as _t
|
|
1744
|
+
import struct as _st
|
|
1745
|
+
import base64 as _b64
|
|
1752
1746
|
try:
|
|
1753
1747
|
import pylibsrtp as _pls
|
|
1754
1748
|
except Exception:
|
|
@@ -2035,7 +2029,7 @@ def _load_sprop(devid: str) -> "Optional[str]":
|
|
|
2035
2029
|
stream), or None. Fail-safe: any error -> None (SDP omits sprop = today's
|
|
2036
2030
|
behaviour)."""
|
|
2037
2031
|
try:
|
|
2038
|
-
with open(_sprop_cache_path(devid)
|
|
2032
|
+
with open(_sprop_cache_path(devid)) as fh:
|
|
2039
2033
|
s = fh.read().strip()
|
|
2040
2034
|
return s or None
|
|
2041
2035
|
except OSError:
|
|
@@ -2911,7 +2905,7 @@ class CameraMixin:
|
|
|
2911
2905
|
self._ice_config_fetched_at = 0.0
|
|
2912
2906
|
try:
|
|
2913
2907
|
return bool(await cb())
|
|
2914
|
-
except Exception as exc:
|
|
2908
|
+
except Exception as exc:
|
|
2915
2909
|
_LOGGER.debug("token refresh failed for %s: %s", self.device_id, exc)
|
|
2916
2910
|
return False
|
|
2917
2911
|
|
|
@@ -3544,7 +3538,7 @@ class CameraMixin:
|
|
|
3544
3538
|
try:
|
|
3545
3539
|
if await lan.async_set_attributes({attr: value}):
|
|
3546
3540
|
return True
|
|
3547
|
-
except Exception as _exc:
|
|
3541
|
+
except Exception as _exc:
|
|
3548
3542
|
_LOGGER.debug("LAN set %s failed (%s); falling back to cloud", attr, _exc)
|
|
3549
3543
|
|
|
3550
3544
|
device_id = self.device_id
|
|
@@ -3579,7 +3573,7 @@ class CameraMixin:
|
|
|
3579
3573
|
# poll, so only known attributes update (others are no-ops).
|
|
3580
3574
|
try:
|
|
3581
3575
|
self.status.update_from_camera_attributes({attr: value})
|
|
3582
|
-
except Exception:
|
|
3576
|
+
except Exception:
|
|
3583
3577
|
pass
|
|
3584
3578
|
return ok
|
|
3585
3579
|
|
|
@@ -3946,7 +3940,8 @@ class CameraMixin:
|
|
|
3946
3940
|
|
|
3947
3941
|
Returns the CDN URL string, or None if no events exist.
|
|
3948
3942
|
"""
|
|
3949
|
-
import aiohttp
|
|
3943
|
+
import aiohttp
|
|
3944
|
+
import time
|
|
3950
3945
|
|
|
3951
3946
|
end_ts = int(time.time() * 1000)
|
|
3952
3947
|
start_ts = end_ts - 30 * 86_400_000 # look back 30 days
|
|
@@ -4041,7 +4036,7 @@ class CameraMixin:
|
|
|
4041
4036
|
_session.wait_done(),
|
|
4042
4037
|
timeout=_sdes_snap_seconds + 80,
|
|
4043
4038
|
)
|
|
4044
|
-
except (
|
|
4039
|
+
except (TimeoutError, _asyncio.CancelledError):
|
|
4045
4040
|
pass
|
|
4046
4041
|
finally:
|
|
4047
4042
|
await _session.stop()
|
|
@@ -4104,7 +4099,7 @@ class CameraMixin:
|
|
|
4104
4099
|
try:
|
|
4105
4100
|
try:
|
|
4106
4101
|
await _asyncio.wait_for(frame_event.wait(), timeout=timeout)
|
|
4107
|
-
except
|
|
4102
|
+
except TimeoutError:
|
|
4108
4103
|
_LOGGER.warning(
|
|
4109
4104
|
"async_snapshot: no keyframe received within %.0fs for %s",
|
|
4110
4105
|
timeout, self.device_id,
|
|
@@ -4217,7 +4212,7 @@ class CameraMixin:
|
|
|
4217
4212
|
try:
|
|
4218
4213
|
res = self._motion_cb(it)
|
|
4219
4214
|
if asyncio.iscoroutine(res):
|
|
4220
|
-
|
|
4215
|
+
_spawn_bg(res)
|
|
4221
4216
|
except Exception:
|
|
4222
4217
|
_LOGGER.debug("motion callback raised", exc_info=True)
|
|
4223
4218
|
primed = True
|
|
@@ -4301,7 +4296,7 @@ class CameraMixin:
|
|
|
4301
4296
|
try:
|
|
4302
4297
|
await asyncio.wait_for(ev.wait(), timeout=timeout)
|
|
4303
4298
|
return True
|
|
4304
|
-
except
|
|
4299
|
+
except TimeoutError:
|
|
4305
4300
|
return False
|
|
4306
4301
|
|
|
4307
4302
|
async def async_speak(
|
|
@@ -4364,7 +4359,7 @@ class CameraMixin:
|
|
|
4364
4359
|
return False
|
|
4365
4360
|
try:
|
|
4366
4361
|
await asyncio.wait_for(done.wait(), timeout=max_seconds)
|
|
4367
|
-
except
|
|
4362
|
+
except TimeoutError:
|
|
4368
4363
|
pass
|
|
4369
4364
|
try:
|
|
4370
4365
|
await session.async_stop_talk()
|
|
@@ -4382,7 +4377,7 @@ class CameraMixin:
|
|
|
4382
4377
|
found = False
|
|
4383
4378
|
for _path in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
4384
4379
|
try:
|
|
4385
|
-
with open(_path
|
|
4380
|
+
with open(_path) as _fh:
|
|
4386
4381
|
_txt = _fh.read()
|
|
4387
4382
|
except OSError:
|
|
4388
4383
|
continue
|
|
@@ -4773,7 +4768,7 @@ class CameraMixin:
|
|
|
4773
4768
|
_disc_since = [None]
|
|
4774
4769
|
_DISC_DEBOUNCE = float(os.environ.get("AIDOT_ICE_DISCONNECT_S", "8"))
|
|
4775
4770
|
|
|
4776
|
-
def _pc_dead() -> bool:
|
|
4771
|
+
def _pc_dead(pc=pc, _disc_since=_disc_since, _DISC_DEBOUNCE=_DISC_DEBOUNCE) -> bool:
|
|
4777
4772
|
_st = getattr(pc, "connectionState", "closed")
|
|
4778
4773
|
if _st in ("closed", "failed"):
|
|
4779
4774
|
return True
|
|
@@ -5184,7 +5179,7 @@ class CameraMixin:
|
|
|
5184
5179
|
session = TutkStreamSession(uid=uid, on_frame=on_frame)
|
|
5185
5180
|
try:
|
|
5186
5181
|
ok = await asyncio.wait_for(session.start(), timeout=timeout)
|
|
5187
|
-
except
|
|
5182
|
+
except TimeoutError:
|
|
5188
5183
|
_LOGGER.error(
|
|
5189
5184
|
"async_open_live_stream: TUTK connect timed out after %.0fs for %s",
|
|
5190
5185
|
timeout, self.device_id,
|
|
@@ -5251,9 +5246,7 @@ class CameraMixin:
|
|
|
5251
5246
|
try:
|
|
5252
5247
|
msg = json.loads(raw)
|
|
5253
5248
|
inner = (msg.get("payload") or msg.get("data") or msg)
|
|
5254
|
-
if isinstance(inner, dict) and ("app" in inner or "dev" in inner):
|
|
5255
|
-
result["data"] = inner
|
|
5256
|
-
elif isinstance(inner, dict) and "data" not in result:
|
|
5249
|
+
if (isinstance(inner, dict) and ("app" in inner or "dev" in inner)) or (isinstance(inner, dict) and "data" not in result):
|
|
5257
5250
|
result["data"] = inner
|
|
5258
5251
|
except Exception:
|
|
5259
5252
|
result["data"] = raw
|
|
@@ -6146,8 +6139,8 @@ class CameraMixin:
|
|
|
6146
6139
|
"setKeepAliveTime HTTP failed for %s: %s", device_id, _ke
|
|
6147
6140
|
)
|
|
6148
6141
|
|
|
6149
|
-
|
|
6150
|
-
|
|
6142
|
+
_spawn_bg(_http_wake())
|
|
6143
|
+
_spawn_bg(_http_keepalive())
|
|
6151
6144
|
|
|
6152
6145
|
outgoing_q.put_nowait(
|
|
6153
6146
|
(f"iot/v1/s/{user_id}/IPC/getIceConfigReq", _ice_req_payload)
|
|
@@ -6156,7 +6149,7 @@ class CameraMixin:
|
|
|
6156
6149
|
try:
|
|
6157
6150
|
await asyncio.wait_for(camera_ready_ev.wait(), timeout=12.0)
|
|
6158
6151
|
_status("Camera awake - got MQTT signal")
|
|
6159
|
-
except
|
|
6152
|
+
except TimeoutError:
|
|
6160
6153
|
# First 12s window elapsed without a camera MQTT signal.
|
|
6161
6154
|
# Resend both the wake signal and ICE config req, then give
|
|
6162
6155
|
# a shorter 5s window before proceeding regardless - cameras
|
|
@@ -6170,11 +6163,11 @@ class CameraMixin:
|
|
|
6170
6163
|
outgoing_q.put_nowait(
|
|
6171
6164
|
(f"iot/v1/s/{user_id}/IPC/getIceConfigReq", _ice_req_payload)
|
|
6172
6165
|
)
|
|
6173
|
-
|
|
6166
|
+
_spawn_bg(_http_wake())
|
|
6174
6167
|
try:
|
|
6175
6168
|
await asyncio.wait_for(camera_ready_ev.wait(), timeout=5.0)
|
|
6176
6169
|
_status("Camera awake - got MQTT signal (after retry)")
|
|
6177
|
-
except
|
|
6170
|
+
except TimeoutError:
|
|
6178
6171
|
_status("Camera not responding - proceeding anyway")
|
|
6179
6172
|
if use_sdes:
|
|
6180
6173
|
_status(
|
|
@@ -6235,7 +6228,7 @@ class CameraMixin:
|
|
|
6235
6228
|
if not _fast_connect:
|
|
6236
6229
|
try:
|
|
6237
6230
|
await asyncio.wait_for(liveplay_echo_ev.wait(), timeout=0.5)
|
|
6238
|
-
except
|
|
6231
|
+
except TimeoutError:
|
|
6239
6232
|
pass
|
|
6240
6233
|
# livePlayResp carries camera-side accept/reject. If the camera
|
|
6241
6234
|
# rejects start-play, continuing to SDP/ICE causes large STUN churn
|
|
@@ -6257,7 +6250,7 @@ class CameraMixin:
|
|
|
6257
6250
|
raise RuntimeError(
|
|
6258
6251
|
f"livePlay rejected by camera (code={_lp_code}, livePlay={_lp_on})"
|
|
6259
6252
|
)
|
|
6260
|
-
except
|
|
6253
|
+
except TimeoutError:
|
|
6261
6254
|
pass
|
|
6262
6255
|
else:
|
|
6263
6256
|
_status(
|
|
@@ -6278,7 +6271,7 @@ class CameraMixin:
|
|
|
6278
6271
|
try:
|
|
6279
6272
|
await asyncio.wait_for(asyncio.shield(ice_config_fut), timeout=3.0)
|
|
6280
6273
|
_status("getIceConfigResp received (post-livePlayReq)")
|
|
6281
|
-
except
|
|
6274
|
+
except TimeoutError:
|
|
6282
6275
|
pass # proceed without TURN; synthetic candidates are fallback
|
|
6283
6276
|
elif _fast_connect:
|
|
6284
6277
|
_status(
|
|
@@ -6798,7 +6791,7 @@ class CameraMixin:
|
|
|
6798
6791
|
_hb_task = asyncio.ensure_future(_heartbeat_loop())
|
|
6799
6792
|
track_tasks.append(_hb_task)
|
|
6800
6793
|
|
|
6801
|
-
|
|
6794
|
+
_spawn_bg(_dcep_open_then_living())
|
|
6802
6795
|
|
|
6803
6796
|
@_kvs_dc.on("message")
|
|
6804
6797
|
def _on_kvs_dc_message(message) -> None:
|
|
@@ -6866,7 +6859,7 @@ class CameraMixin:
|
|
|
6866
6859
|
)
|
|
6867
6860
|
break
|
|
6868
6861
|
if talk_pcm_provider is None:
|
|
6869
|
-
|
|
6862
|
+
_spawn_bg(_suppress_audio_sender_rtcp())
|
|
6870
6863
|
# Drain decoded audio frames so the queue doesn't grow unbounded.
|
|
6871
6864
|
async def _drain_audio() -> None:
|
|
6872
6865
|
try:
|
|
@@ -6912,7 +6905,7 @@ class CameraMixin:
|
|
|
6912
6905
|
except Exception as _pli_exc:
|
|
6913
6906
|
_LOGGER.debug("RTCP PLI attempt failed: %s", _pli_exc)
|
|
6914
6907
|
_LOGGER.debug("RTCP PLI: no SSRC discovered within 5s")
|
|
6915
|
-
|
|
6908
|
+
_spawn_bg(_request_keyframe())
|
|
6916
6909
|
if on_frame is not None:
|
|
6917
6910
|
t = asyncio.ensure_future(_webrtc_consume_video(track, on_frame))
|
|
6918
6911
|
track_tasks.append(t)
|
|
@@ -7059,7 +7052,7 @@ class CameraMixin:
|
|
|
7059
7052
|
sections.append(_cur_sec)
|
|
7060
7053
|
|
|
7061
7054
|
# Find mid:1 (H264) section to clone transport attrs for H265.
|
|
7062
|
-
_h264_sec = next((s for s in sections if any('a=mid:1'
|
|
7055
|
+
_h264_sec = next((s for s in sections if any(l.rstrip() == 'a=mid:1' for l in s)), None)
|
|
7063
7056
|
_ufrag = _pwd = _fp = _setup = ''
|
|
7064
7057
|
if _h264_sec:
|
|
7065
7058
|
for _l in _h264_sec:
|
|
@@ -7093,7 +7086,7 @@ class CameraMixin:
|
|
|
7093
7086
|
# Renumber DC section: mid:2 → mid:3.
|
|
7094
7087
|
new_sections: list[list[str]] = []
|
|
7095
7088
|
for sec in sections:
|
|
7096
|
-
if any('a=mid:2'
|
|
7089
|
+
if any(l.rstrip() == 'a=mid:2' for l in sec) and sec[0].startswith('m=application'):
|
|
7097
7090
|
new_sections.append([l.replace('a=mid:2', 'a=mid:3') if l.rstrip() == 'a=mid:2' else l for l in sec])
|
|
7098
7091
|
else:
|
|
7099
7092
|
new_sections.append(sec)
|
|
@@ -7102,7 +7095,7 @@ class CameraMixin:
|
|
|
7102
7095
|
result_secs: list[list[str]] = []
|
|
7103
7096
|
for sec in new_sections:
|
|
7104
7097
|
result_secs.append(sec)
|
|
7105
|
-
if any('a=mid:1'
|
|
7098
|
+
if any(l.rstrip() == 'a=mid:1' for l in sec):
|
|
7106
7099
|
result_secs.append(_h265_sec)
|
|
7107
7100
|
|
|
7108
7101
|
# Update a=group:BUNDLE in header.
|
|
@@ -8933,7 +8926,6 @@ class CameraMixin:
|
|
|
8933
8926
|
import base64
|
|
8934
8927
|
import os
|
|
8935
8928
|
import subprocess
|
|
8936
|
-
import tempfile
|
|
8937
8929
|
import json
|
|
8938
8930
|
|
|
8939
8931
|
# _open_sdes_stream runs in its own scope, so recompute the fast-connect
|
|
@@ -9067,7 +9059,11 @@ class CameraMixin:
|
|
|
9067
9059
|
def _turn_allocate_udp(_ta_sock, _ta_host, _ta_port, _ta_user, _ta_pass):
|
|
9068
9060
|
"""RFC 5766 TURN relay allocation with long-term credential auth.
|
|
9069
9061
|
Returns (relay_ip, relay_port, realm, nonce) or None on failure."""
|
|
9070
|
-
import hashlib as _ha
|
|
9062
|
+
import hashlib as _ha
|
|
9063
|
+
import hmac as _hm
|
|
9064
|
+
import struct as _st_ta
|
|
9065
|
+
import select as _sl_ta
|
|
9066
|
+
import time as _tm_ta
|
|
9071
9067
|
|
|
9072
9068
|
_MAGIC_TA = b'\x21\x12\xa4\x42'
|
|
9073
9069
|
|
|
@@ -9181,7 +9177,7 @@ class CameraMixin:
|
|
|
9181
9177
|
_o += 4 + _al + (-_al % 4)
|
|
9182
9178
|
if _at == 0x0016 and _al >= 8: # XOR-RELAYED-ADDRESS
|
|
9183
9179
|
_xp = _st_ta.unpack_from('!H', _av, 2)[0] ^ 0x2112
|
|
9184
|
-
_xb = bytes(a ^ b for a, b in zip(_av[4:8], _MAGIC_TA))
|
|
9180
|
+
_xb = bytes(a ^ b for a, b in zip(_av[4:8], _MAGIC_TA, strict=False))
|
|
9185
9181
|
_r_ip_ta = '.'.join(str(b) for b in _xb)
|
|
9186
9182
|
# Do NOT pre-create permissions for our own srflx IP or
|
|
9187
9183
|
# TURN server IP. That can cause TURN self-loop Data
|
|
@@ -9198,7 +9194,8 @@ class CameraMixin:
|
|
|
9198
9194
|
# path connects without waiting on a cloud TURN Allocate round-trip.
|
|
9199
9195
|
if _sdes_turn_entries and not _fast_connect:
|
|
9200
9196
|
try:
|
|
9201
|
-
import re as _re_pre
|
|
9197
|
+
import re as _re_pre
|
|
9198
|
+
import hashlib as _hlk_pre
|
|
9202
9199
|
_our_te_pre = next(
|
|
9203
9200
|
(e for e in _sdes_turn_entries if e.get("Username") == user_id),
|
|
9204
9201
|
_sdes_turn_entries[0],
|
|
@@ -9244,7 +9241,8 @@ class CameraMixin:
|
|
|
9244
9241
|
from cryptography.hazmat.primitives import hashes as _ch, serialization as _cser
|
|
9245
9242
|
from cryptography.hazmat.primitives.asymmetric import ec as _cec
|
|
9246
9243
|
from cryptography.hazmat.backends import default_backend as _cbd
|
|
9247
|
-
import datetime as _dt_dc
|
|
9244
|
+
import datetime as _dt_dc
|
|
9245
|
+
import hashlib as _hs_dc
|
|
9248
9246
|
_dc_key = _cec.generate_private_key(_cec.SECP256R1(), _cbd())
|
|
9249
9247
|
_dc_name = _cx509.Name([_cx509.NameAttribute(_CNOID.COMMON_NAME, "aidot-dc")])
|
|
9250
9248
|
_dc_cert = (
|
|
@@ -9465,7 +9463,7 @@ class CameraMixin:
|
|
|
9465
9463
|
try:
|
|
9466
9464
|
await _asyncio.wait_for(liveplay_echo_ev.wait(), timeout=5.0)
|
|
9467
9465
|
_status("livePlayReq echo received - sending webrtcReq, ICE, then launching ffmpeg")
|
|
9468
|
-
except
|
|
9466
|
+
except TimeoutError:
|
|
9469
9467
|
_status("no livePlayReq echo in 5s - sending webrtcReq, ICE, then launching ffmpeg anyway")
|
|
9470
9468
|
# If camera provided explicit livePlayResp failure, abort before SDP/ICE.
|
|
9471
9469
|
try:
|
|
@@ -9478,7 +9476,7 @@ class CameraMixin:
|
|
|
9478
9476
|
raise RuntimeError(
|
|
9479
9477
|
f"livePlay rejected by camera (code={_lp_code_sdes}, livePlay={_lp_on_sdes})"
|
|
9480
9478
|
)
|
|
9481
|
-
except
|
|
9479
|
+
except TimeoutError:
|
|
9482
9480
|
pass
|
|
9483
9481
|
|
|
9484
9482
|
# --- Build local-receiver SDP for ffmpeg ----------------------------- #
|
|
@@ -9686,7 +9684,8 @@ class CameraMixin:
|
|
|
9686
9684
|
# and _relay_addrs is populated - skip to avoid double-allocation.
|
|
9687
9685
|
if not _relay_addrs:
|
|
9688
9686
|
try:
|
|
9689
|
-
import re as _re_relay_e
|
|
9687
|
+
import re as _re_relay_e
|
|
9688
|
+
import hashlib as _hlk_e
|
|
9690
9689
|
_our_te = next(
|
|
9691
9690
|
(e for e in _sdes_turn_entries if e.get("Username") == user_id),
|
|
9692
9691
|
_sdes_turn_entries[0] if _sdes_turn_entries else None,
|
|
@@ -9833,7 +9832,7 @@ class CameraMixin:
|
|
|
9833
9832
|
f" audio={_ans_audio_ip}:{_ans_audio_port}"
|
|
9834
9833
|
f" video={_ans_video_ip}:{_ans_video_port})"
|
|
9835
9834
|
)
|
|
9836
|
-
except
|
|
9835
|
+
except TimeoutError:
|
|
9837
9836
|
pass # no echo - camera uses a different signalling variant; proceed
|
|
9838
9837
|
|
|
9839
9838
|
# --- Announce our ICE candidates via MQTT (iceCandidateReq) ----------- #
|
|
@@ -9891,7 +9890,8 @@ class CameraMixin:
|
|
|
9891
9890
|
# candidate trickle on a cloud round-trip).
|
|
9892
9891
|
if not _relay_addrs and not _fast_connect:
|
|
9893
9892
|
try:
|
|
9894
|
-
import re as _re_relay
|
|
9893
|
+
import re as _re_relay
|
|
9894
|
+
import hashlib as _hlk
|
|
9895
9895
|
_our_turn_entry = None
|
|
9896
9896
|
for _te in _sdes_turn_entries:
|
|
9897
9897
|
if _te.get("Username") == user_id:
|
|
@@ -10037,7 +10037,8 @@ class CameraMixin:
|
|
|
10037
10037
|
# exit quickly so the DTLS fallback starts sooner.
|
|
10038
10038
|
# SRTP early exit: if a non-STUN packet arrives (SRTP), ICE is done -
|
|
10039
10039
|
# close sockets immediately so ffmpeg can bind.
|
|
10040
|
-
import struct as _struct
|
|
10040
|
+
import struct as _struct
|
|
10041
|
+
import select as _select
|
|
10041
10042
|
_STUN_MAGIC = b'\x21\x12\xa4\x42'
|
|
10042
10043
|
_stun_count = 0
|
|
10043
10044
|
_stun_seen = False
|
|
@@ -10113,7 +10114,7 @@ class CameraMixin:
|
|
|
10113
10114
|
if _sw_at == 0x0012 and _sw_al >= 8: # XOR-PEER-ADDRESS
|
|
10114
10115
|
_sw_xp = _struct.unpack_from('!H', _sw_av, 2)[0] ^ 0x2112
|
|
10115
10116
|
_sw_xb = bytes(
|
|
10116
|
-
a ^ b for a, b in zip(_sw_av[4:8], _STUN_MAGIC)
|
|
10117
|
+
a ^ b for a, b in zip(_sw_av[4:8], _STUN_MAGIC, strict=False)
|
|
10117
10118
|
)
|
|
10118
10119
|
_turn_peer_ip_sw = '.'.join(str(b) for b in _sw_xb)
|
|
10119
10120
|
_turn_peer_port_sw = _sw_xp
|
|
@@ -10162,7 +10163,7 @@ class CameraMixin:
|
|
|
10162
10163
|
int(x) for x in _turn_peer_ip_sw.split('.')
|
|
10163
10164
|
)
|
|
10164
10165
|
_si_xip = bytes(
|
|
10165
|
-
a ^ b for a, b in zip(_si_pip, _STUN_MAGIC)
|
|
10166
|
+
a ^ b for a, b in zip(_si_pip, _STUN_MAGIC, strict=False)
|
|
10166
10167
|
)
|
|
10167
10168
|
_si_xport = (_turn_peer_port_sw ^ 0x2112) & 0xFFFF
|
|
10168
10169
|
_si_xpa = (b'\x00\x01'
|
|
@@ -10500,7 +10501,8 @@ class CameraMixin:
|
|
|
10500
10501
|
return raw + b'\x00' * ((-len(raw)) % 4)
|
|
10501
10502
|
|
|
10502
10503
|
def _sctp_init():
|
|
10503
|
-
import struct as _st_sc
|
|
10504
|
+
import struct as _st_sc
|
|
10505
|
+
import random as _r_sc
|
|
10504
10506
|
if _sctp['local_tag'] == 0:
|
|
10505
10507
|
_sctp['local_tag'] = _r_sc.randint(1, 0xFFFFFFFF)
|
|
10506
10508
|
_sctp['local_tsn'] = _r_sc.randint(1, 0xFFFFFFFF)
|
|
@@ -10550,7 +10552,8 @@ class CameraMixin:
|
|
|
10550
10552
|
return None
|
|
10551
10553
|
|
|
10552
10554
|
def _sctp_init_ack_pkt():
|
|
10553
|
-
import struct as _st_sc
|
|
10555
|
+
import struct as _st_sc
|
|
10556
|
+
import random as _r_sc
|
|
10554
10557
|
# RFC 4960 §5.2.1: reuse local_tag/tsn from our INIT in simultaneous open
|
|
10555
10558
|
if _sctp['local_tag'] == 0:
|
|
10556
10559
|
_sctp['local_tag'] = _r_sc.randint(1, 0xFFFFFFFF)
|
|
@@ -10581,7 +10584,9 @@ class CameraMixin:
|
|
|
10581
10584
|
+ label)
|
|
10582
10585
|
|
|
10583
10586
|
def _session_mode_req_msg():
|
|
10584
|
-
import struct as _st_sc
|
|
10587
|
+
import struct as _st_sc
|
|
10588
|
+
import random as _r_sc
|
|
10589
|
+
import time as _t_sc
|
|
10585
10590
|
seq = _r_sc.randint(0, 0x7FFFFFFF)
|
|
10586
10591
|
ts = int(_t_sc.time() * 1000)
|
|
10587
10592
|
return (_st_sc.pack('<IIqII4x', seq, 5376, ts, 8, 0)
|
|
@@ -10630,7 +10635,10 @@ class CameraMixin:
|
|
|
10630
10635
|
|
|
10631
10636
|
def _send_use_candidate(sock, our_ufrag, our_pwd, cam_ufrag, cam_pwd, cam_addr):
|
|
10632
10637
|
"""Send a STUN Binding Request with ICE-CONTROLLING + USE-CANDIDATE."""
|
|
10633
|
-
import struct as _st_uc
|
|
10638
|
+
import struct as _st_uc
|
|
10639
|
+
import os as _os_uc
|
|
10640
|
+
import hmac as _hm_uc
|
|
10641
|
+
import hashlib as _hs_uc
|
|
10634
10642
|
_MAGIC_UC = b'\x21\x12\xa4\x42'
|
|
10635
10643
|
_tid_uc = _os_uc.urandom(12)
|
|
10636
10644
|
_user = f"{cam_ufrag}:{our_ufrag}".encode()
|
|
@@ -10702,7 +10710,8 @@ class CameraMixin:
|
|
|
10702
10710
|
# • forwards all non-STUN packets (SRTP) to ffmpeg's loopback ports
|
|
10703
10711
|
# When the session ends, SdesSession.stop() closes the original sockets,
|
|
10704
10712
|
# which causes the bridge thread's select() to raise and it exits cleanly.
|
|
10705
|
-
import threading as _threading_br
|
|
10713
|
+
import threading as _threading_br
|
|
10714
|
+
import socket as _socket_br
|
|
10706
10715
|
|
|
10707
10716
|
def _alloc_lo_port():
|
|
10708
10717
|
_s = _socket_br.socket(_socket_br.AF_INET, _socket_br.SOCK_DGRAM)
|
|
@@ -10785,7 +10794,9 @@ class CameraMixin:
|
|
|
10785
10794
|
nonlocal _br_first_audio_logged, _br_first_video_logged, _avio_living_sent
|
|
10786
10795
|
nonlocal _bridge_selfloop_drop_count # incremented below; needs nonlocal
|
|
10787
10796
|
_STUN_MAGIC_BR = b'\x21\x12\xa4\x42'
|
|
10788
|
-
import struct as _st_br
|
|
10797
|
+
import struct as _st_br
|
|
10798
|
+
import select as _sel_br
|
|
10799
|
+
import time as _time_br
|
|
10789
10800
|
_br_prefer_direct_stun = {_audio_sock: False, _video_sock: False}
|
|
10790
10801
|
_br_last_uc = 0.0
|
|
10791
10802
|
_br_stun_resp_count = 0
|
|
@@ -10841,7 +10852,8 @@ class CameraMixin:
|
|
|
10841
10852
|
if (_sdes_probe_received
|
|
10842
10853
|
and _hb_sock is not None and _hb_src is not None
|
|
10843
10854
|
and _time_br.time() - _last_hb_ts >= 10.0):
|
|
10844
|
-
import struct as _st_hb
|
|
10855
|
+
import struct as _st_hb
|
|
10856
|
+
import random as _r_hb
|
|
10845
10857
|
_hb_seq = _r_hb.randint(0, 0x7FFFFFFF)
|
|
10846
10858
|
_hb_ts = int(_time_br.time() * 1000)
|
|
10847
10859
|
# 28-byte AVIO header, cmd=5156 (HEARTHEAT_REQ), empty payload.
|
|
@@ -10994,7 +11006,8 @@ class CameraMixin:
|
|
|
10994
11006
|
# after the encrypted-SCTP handshake defines them; same closure
|
|
10995
11007
|
# pattern as the LIVING send above). dcep_sock/dcep_src are the
|
|
10996
11008
|
# socket+addr the SCTP handshake (and LIVING) used.
|
|
10997
|
-
import struct as _st_pcmd
|
|
11009
|
+
import struct as _st_pcmd
|
|
11010
|
+
import random as _r_pcmd
|
|
10998
11011
|
|
|
10999
11012
|
def _persistent_sdes_cmd(_cmd, _extra=b''):
|
|
11000
11013
|
_seq = _r_pcmd.randint(0, 0x7FFFFFFF)
|
|
@@ -11008,7 +11021,7 @@ class CameraMixin:
|
|
|
11008
11021
|
try:
|
|
11009
11022
|
_chunk = _sctp_data(53, _avio)
|
|
11010
11023
|
_csock.sendto(
|
|
11011
|
-
_enc_c8_sctp(_sctp_pkt(_sctp['peer_tag'], _chunk)),
|
|
11024
|
+
_enc_c8_sctp(_sctp_pkt(_sctp['peer_tag'], _chunk)),
|
|
11012
11025
|
_csrc,
|
|
11013
11026
|
)
|
|
11014
11027
|
except Exception:
|
|
@@ -11024,7 +11037,8 @@ class CameraMixin:
|
|
|
11024
11037
|
and not _sdes_probe_received
|
|
11025
11038
|
and _trigger_bs is not None
|
|
11026
11039
|
and _time_br.time() - _last_trigger_ts >= 2.0):
|
|
11027
|
-
import struct as _st_re2
|
|
11040
|
+
import struct as _st_re2
|
|
11041
|
+
import random as _r_re2
|
|
11028
11042
|
_re_ts = int(_time_br.time() * 1000)
|
|
11029
11043
|
_re_seq = _r_re2.randint(0, 0x7FFFFFFF)
|
|
11030
11044
|
_re_plain = (
|
|
@@ -11088,7 +11102,7 @@ class CameraMixin:
|
|
|
11088
11102
|
'!H', _br_av, 2)[0] ^ 0x2112)
|
|
11089
11103
|
_br_xb = bytes(
|
|
11090
11104
|
a ^ b for a, b in zip(
|
|
11091
|
-
_br_av[4:8], _STUN_MAGIC_BR)
|
|
11105
|
+
_br_av[4:8], _STUN_MAGIC_BR, strict=False)
|
|
11092
11106
|
)
|
|
11093
11107
|
_br_turn_peer_ip = '.'.join(
|
|
11094
11108
|
str(b) for b in _br_xb)
|
|
@@ -11143,7 +11157,7 @@ class CameraMixin:
|
|
|
11143
11157
|
)
|
|
11144
11158
|
_br_xip2 = bytes(
|
|
11145
11159
|
a ^ b for a, b in zip(
|
|
11146
|
-
_br_pip, _STUN_MAGIC_BR)
|
|
11160
|
+
_br_pip, _STUN_MAGIC_BR, strict=False)
|
|
11147
11161
|
)
|
|
11148
11162
|
_br_xport2 = (
|
|
11149
11163
|
_br_turn_peer_port ^ 0x2112) & 0xFFFF
|
|
@@ -12248,7 +12262,7 @@ class CameraMixin:
|
|
|
12248
12262
|
_second_ans = await asyncio.wait_for(
|
|
12249
12263
|
asyncio.shield(second_answer_fut), timeout=5.0
|
|
12250
12264
|
)
|
|
12251
|
-
except
|
|
12265
|
+
except TimeoutError:
|
|
12252
12266
|
_second_ans = None
|
|
12253
12267
|
elif second_answer_fut is not None and second_answer_fut.done():
|
|
12254
12268
|
try:
|
|
@@ -12332,7 +12346,7 @@ class CameraMixin:
|
|
|
12332
12346
|
stderr=subprocess.PIPE,
|
|
12333
12347
|
)
|
|
12334
12348
|
_status("ffmpeg restarted with camera's SRTP keys")
|
|
12335
|
-
except
|
|
12349
|
+
except TimeoutError:
|
|
12336
12350
|
if _cam_echo_received:
|
|
12337
12351
|
# We already sent webrtcResp in response to the camera's echo.
|
|
12338
12352
|
# The camera should now be streaming; keep ffmpeg running.
|
|
@@ -12420,7 +12434,7 @@ class CameraMixin:
|
|
|
12420
12434
|
)
|
|
12421
12435
|
except Exception:
|
|
12422
12436
|
pass
|
|
12423
|
-
|
|
12437
|
+
_spawn_bg(_late_second_answer_task())
|
|
12424
12438
|
|
|
12425
12439
|
return SdesSession(
|
|
12426
12440
|
proc=proc,
|
|
@@ -97,11 +97,11 @@ async def discover_unicast(ip: str, timeout: float = 2.0) -> Optional[dict]:
|
|
|
97
97
|
await loop.sock_sendto(sock, enc, (ip, _DISCOVER_PORT))
|
|
98
98
|
try:
|
|
99
99
|
data = await asyncio.wait_for(loop.sock_recv(sock, 8192), timeout)
|
|
100
|
-
except
|
|
100
|
+
except TimeoutError:
|
|
101
101
|
return None
|
|
102
102
|
try:
|
|
103
103
|
return json.loads(aes_decrypt(data, _DISCOVER_KEY)).get("payload")
|
|
104
|
-
except Exception:
|
|
104
|
+
except Exception:
|
|
105
105
|
return None
|
|
106
106
|
finally:
|
|
107
107
|
sock.close()
|
|
@@ -221,7 +221,7 @@ class CameraLanClient:
|
|
|
221
221
|
reader, writer = await asyncio.wait_for(
|
|
222
222
|
asyncio.open_connection(self._ip, _CONTROL_PORT), timeout=5.0
|
|
223
223
|
)
|
|
224
|
-
except (
|
|
224
|
+
except (TimeoutError, OSError) as exc:
|
|
225
225
|
raise CameraLanError(f"{self.device_id}: connect failed: {exc}") from exc
|
|
226
226
|
try:
|
|
227
227
|
asc = await self._login(reader, writer)
|
|
@@ -236,7 +236,7 @@ class CameraLanClient:
|
|
|
236
236
|
for _ in range(4):
|
|
237
237
|
try:
|
|
238
238
|
raw = await _read_frame(reader, timeout=4.0)
|
|
239
|
-
except (
|
|
239
|
+
except (TimeoutError, asyncio.IncompleteReadError):
|
|
240
240
|
break
|
|
241
241
|
frame = json.loads(aes_decrypt(raw, self._key))
|
|
242
242
|
replies.append(frame)
|
|
@@ -247,7 +247,7 @@ class CameraLanClient:
|
|
|
247
247
|
writer.close()
|
|
248
248
|
try:
|
|
249
249
|
await writer.wait_closed()
|
|
250
|
-
except Exception:
|
|
250
|
+
except Exception:
|
|
251
251
|
pass
|
|
252
252
|
|
|
253
253
|
async def _login(self, reader, writer) -> int:
|
|
@@ -346,7 +346,7 @@ class AidotClient:
|
|
|
346
346
|
result = await self._do_ensure_token()
|
|
347
347
|
fut.set_result(result)
|
|
348
348
|
return result
|
|
349
|
-
except Exception as exc:
|
|
349
|
+
except Exception as exc:
|
|
350
350
|
fut.set_exception(exc)
|
|
351
351
|
raise
|
|
352
352
|
finally:
|
|
@@ -365,14 +365,14 @@ class AidotClient:
|
|
|
365
365
|
return True
|
|
366
366
|
except AidotAuthFailed:
|
|
367
367
|
pass
|
|
368
|
-
except Exception as exc:
|
|
368
|
+
except Exception as exc:
|
|
369
369
|
_LOGGER.debug("async_ensure_token: refresh failed: %s", exc)
|
|
370
370
|
try:
|
|
371
371
|
await self.async_post_login()
|
|
372
372
|
if self._token_fresh_cb:
|
|
373
373
|
self._token_fresh_cb()
|
|
374
374
|
return True
|
|
375
|
-
except Exception as exc:
|
|
375
|
+
except Exception as exc:
|
|
376
376
|
_LOGGER.warning("async_ensure_token: re-login failed: %s", exc)
|
|
377
377
|
return False
|
|
378
378
|
|
|
@@ -233,7 +233,7 @@ class ServerErrorCode(IntEnum):
|
|
|
233
233
|
|
|
234
234
|
# ── Cloud API credentials ──────────────────────────────────────────────────── #
|
|
235
235
|
|
|
236
|
-
from .login_const import APP_ID
|
|
236
|
+
from .login_const import APP_ID # noqa: E402, F401 - back-compat re-export
|
|
237
237
|
from .login_const import API_URL_TEMPLATE as _API_URL_TEMPLATE, DEFAULT_REGION as _DEFAULT_REGION # noqa: E402
|
|
238
238
|
BASE_URL = _API_URL_TEMPLATE.format(region=_DEFAULT_REGION)
|
|
239
239
|
|
|
@@ -8,7 +8,7 @@ import json
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import logging
|
|
10
10
|
from datetime import datetime
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import ClassVar, Any
|
|
12
12
|
|
|
13
13
|
from .exceptions import AidotNotLogin
|
|
14
14
|
from .aes_utils import aes_encrypt, aes_decrypt_to_json
|
|
@@ -43,6 +43,9 @@ from .const import (
|
|
|
43
43
|
|
|
44
44
|
_LOGGER = logging.getLogger(__name__)
|
|
45
45
|
|
|
46
|
+
# Strong refs to fire-and-forget tasks (see camera/client.py).
|
|
47
|
+
_DC_BG_TASKS: set = set()
|
|
48
|
+
|
|
46
49
|
|
|
47
50
|
class DeviceStatusData:
|
|
48
51
|
online: bool = False
|
|
@@ -67,7 +70,7 @@ class DeviceStatusData:
|
|
|
67
70
|
self.rgdb = 0xFF000000 # Red in int: 4278190080
|
|
68
71
|
else:
|
|
69
72
|
self.rgdb = rgbw_value
|
|
70
|
-
|
|
73
|
+
|
|
71
74
|
rgbw = ctypes.c_uint32(self.rgdb).value
|
|
72
75
|
r = (rgbw >> 24) & 0xFF
|
|
73
76
|
g = (rgbw >> 16) & 0xFF
|
|
@@ -139,7 +142,7 @@ from .camera.client import (
|
|
|
139
142
|
)
|
|
140
143
|
|
|
141
144
|
|
|
142
|
-
class DeviceClient(CameraMixin
|
|
145
|
+
class DeviceClient(CameraMixin):
|
|
143
146
|
status: DeviceStatusData
|
|
144
147
|
info: DeviceInformation
|
|
145
148
|
_login_uuid = 0
|
|
@@ -156,7 +159,7 @@ class DeviceClient(CameraMixin, object):
|
|
|
156
159
|
_ping_timer: Any = None
|
|
157
160
|
writer: Any = None
|
|
158
161
|
reader: Any = None
|
|
159
|
-
syncProperties = [CONF_ON_OFF, CONF_DIMMING, CONF_RGBW, CONF_CCT]
|
|
162
|
+
syncProperties: ClassVar = [CONF_ON_OFF, CONF_DIMMING, CONF_RGBW, CONF_CCT]
|
|
160
163
|
heart_time = 30
|
|
161
164
|
ping_data = PingRequest().to_dict()
|
|
162
165
|
_TAG: str = "DeviceClient"
|
|
@@ -219,7 +222,7 @@ class DeviceClient(CameraMixin, object):
|
|
|
219
222
|
if self._connecting is not True and self._connect_and_login is not True:
|
|
220
223
|
self._login_task = asyncio.create_task(self.async_login())
|
|
221
224
|
|
|
222
|
-
|
|
225
|
+
|
|
223
226
|
async def async_login(self) -> None:
|
|
224
227
|
if self._ip_address is None:
|
|
225
228
|
return
|
|
@@ -239,16 +242,16 @@ class DeviceClient(CameraMixin, object):
|
|
|
239
242
|
packet = magic + _msgtype + bodysize + send_data
|
|
240
243
|
|
|
241
244
|
return packet
|
|
242
|
-
|
|
245
|
+
|
|
243
246
|
def _notify_status_update(self) -> None:
|
|
244
247
|
if self.on_status_update:
|
|
245
248
|
self.on_status_update(self.status)
|
|
246
|
-
|
|
249
|
+
|
|
247
250
|
|
|
248
251
|
async def login(self) -> None:
|
|
249
252
|
login_seq = str(int(time.time() * 1000) + self._login_uuid)[-9:]
|
|
250
253
|
self._login_uuid += 1
|
|
251
|
-
|
|
254
|
+
|
|
252
255
|
login_request = LoginRequest(
|
|
253
256
|
seq=login_seq,
|
|
254
257
|
srcAddr=self.user_id,
|
|
@@ -267,7 +270,7 @@ class DeviceClient(CameraMixin, object):
|
|
|
267
270
|
body = await self.reader.readexactly(bodysize)
|
|
268
271
|
json_data = aes_decrypt_to_json(body, self.aes_key)
|
|
269
272
|
_LOGGER.debug(f"{self._TAG}:login result: {json_data}")
|
|
270
|
-
|
|
273
|
+
|
|
271
274
|
response = DeviceResponse.from_json(json_data)
|
|
272
275
|
if response.ack.code != 200:
|
|
273
276
|
# 登录失败
|
|
@@ -319,7 +322,7 @@ class DeviceClient(CameraMixin, object):
|
|
|
319
322
|
response = DeviceResponse.from_json(json_data)
|
|
320
323
|
if response.service == "test":
|
|
321
324
|
continue
|
|
322
|
-
|
|
325
|
+
|
|
323
326
|
if response.payload:
|
|
324
327
|
if response.payload.ascNumber:
|
|
325
328
|
self.ascNumber = response.payload.ascNumber
|
|
@@ -329,16 +332,18 @@ class DeviceClient(CameraMixin, object):
|
|
|
329
332
|
# model; pass the raw dict so they are not dropped.
|
|
330
333
|
self.status.update((json_data.get(CONF_PAYLOAD) or {}).get(CONF_ATTR))
|
|
331
334
|
self._notify_status_update()
|
|
332
|
-
|
|
335
|
+
|
|
333
336
|
def _schedule_ping(self):
|
|
334
337
|
loop = asyncio.get_running_loop()
|
|
335
|
-
loop.create_task(self.send_ping_action())
|
|
338
|
+
_t = loop.create_task(self.send_ping_action())
|
|
339
|
+
_DC_BG_TASKS.add(_t)
|
|
340
|
+
_t.add_done_callback(_DC_BG_TASKS.discard)
|
|
336
341
|
self._ping_timer = loop.call_later(self.heart_time, self._schedule_ping)
|
|
337
342
|
|
|
338
343
|
async def send_dev_attr(self, dev_attr) -> None:
|
|
339
344
|
if not self._connect_and_login:
|
|
340
345
|
raise ConnectionError('Device offline')
|
|
341
|
-
if not self.status.on and not
|
|
346
|
+
if not self.status.on and CONF_ON_OFF not in dev_attr:
|
|
342
347
|
self.status.on = True
|
|
343
348
|
dev_attr[CONF_ON_OFF] = 1
|
|
344
349
|
await self.send_action(dev_attr, CONF_SET_DEV_ATTR_REQ)
|
|
@@ -372,7 +377,7 @@ class DeviceClient(CameraMixin, object):
|
|
|
372
377
|
seq="ha93" + str(self.seq_num).zfill(5),
|
|
373
378
|
simpleVersion=self._simpleVersion,
|
|
374
379
|
)
|
|
375
|
-
|
|
380
|
+
|
|
376
381
|
action = action_request.to_dict()
|
|
377
382
|
_LOGGER.debug(f"{self.device_id} send_action {action}")
|
|
378
383
|
try:
|
|
@@ -396,7 +401,7 @@ class DeviceClient(CameraMixin, object):
|
|
|
396
401
|
return -1
|
|
397
402
|
if self._connect_and_login is False:
|
|
398
403
|
return -1
|
|
399
|
-
|
|
404
|
+
|
|
400
405
|
self.ping_count += 1
|
|
401
406
|
if self.ping_data is not None:
|
|
402
407
|
_LOGGER.debug(f"{self.device_id} send_ping {self.ping_data}")
|
{python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/models/device_client_model.py
RENAMED
|
@@ -177,7 +177,7 @@ class DeviceActionRequest:
|
|
|
177
177
|
ascNumber: int,
|
|
178
178
|
attr: dict[str, Any],
|
|
179
179
|
seq: str,
|
|
180
|
-
simpleVersion: str = None,
|
|
180
|
+
simpleVersion: str | None = None,
|
|
181
181
|
) -> "DeviceActionRequest":
|
|
182
182
|
"""Create DeviceActionRequest from params."""
|
|
183
183
|
if simpleVersion is not None:
|
|
@@ -207,4 +207,4 @@ class DeviceActionRequest:
|
|
|
207
207
|
),
|
|
208
208
|
deviceId=device_id,
|
|
209
209
|
)
|
|
210
|
-
|
|
210
|
+
|
{python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9/src/python_aidot_cameras.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.9
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
{python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_post_merge_hardening.py
RENAMED
|
@@ -13,9 +13,8 @@ import time
|
|
|
13
13
|
|
|
14
14
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
15
15
|
|
|
16
|
-
import pytest
|
|
17
16
|
|
|
18
|
-
from aidot.camera.client import CameraMixin, CameraStatusData
|
|
17
|
+
from aidot.camera.client import CameraMixin, CameraStatusData
|
|
19
18
|
from aidot.client import _is_camera
|
|
20
19
|
from aidot.device_client import DeviceClient, DeviceStatusData
|
|
21
20
|
from aidot.discover import Discover
|
|
@@ -55,7 +55,7 @@ def test_cap_blocks_beyond_limit_then_frees():
|
|
|
55
55
|
try:
|
|
56
56
|
await asyncio.wait_for(sem.acquire(), timeout=0.2)
|
|
57
57
|
raise AssertionError("3rd acquire should have blocked")
|
|
58
|
-
except
|
|
58
|
+
except TimeoutError:
|
|
59
59
|
pass
|
|
60
60
|
sem.release()
|
|
61
61
|
await asyncio.wait_for(sem.acquire(), timeout=0.2) # now proceeds
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=61.0"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.7.8"
|
|
8
|
-
description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
license = "MIT"
|
|
11
|
-
license-files = ["LICENSE"]
|
|
12
|
-
authors = [{ name = "cbrightly", email = "chris.brightly@gmail.com" }]
|
|
13
|
-
requires-python = ">=3.11"
|
|
14
|
-
classifiers = [
|
|
15
|
-
"Programming Language :: Python :: 3",
|
|
16
|
-
"Operating System :: OS Independent",
|
|
17
|
-
]
|
|
18
|
-
dependencies = [
|
|
19
|
-
"aiohttp",
|
|
20
|
-
"paho-mqtt>=2.0",
|
|
21
|
-
"cryptography",
|
|
22
|
-
"pycryptodome",
|
|
23
|
-
"dacite",
|
|
24
|
-
]
|
|
25
|
-
|
|
26
|
-
[project.optional-dependencies]
|
|
27
|
-
webrtc = [
|
|
28
|
-
"aiortc>=1.9.0",
|
|
29
|
-
"av",
|
|
30
|
-
"pylibsrtp",
|
|
31
|
-
"pyopenssl",
|
|
32
|
-
"numpy",
|
|
33
|
-
"Pillow",
|
|
34
|
-
]
|
|
35
|
-
|
|
36
|
-
[project.urls]
|
|
37
|
-
Homepage = "https://github.com/cbrightly/python-AiDot"
|
|
38
|
-
"Issue Tracker" = "https://github.com/cbrightly/python-AiDot/issues"
|
|
39
|
-
|
|
40
|
-
[tool.setuptools.packages.find]
|
|
41
|
-
where = ["src"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -17,16 +17,16 @@ from .exceptions import (
|
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
__all__ = [
|
|
20
|
-
"AidotClient",
|
|
21
|
-
"DeviceClient",
|
|
22
|
-
"Discover",
|
|
23
|
-
"AidotCameraBusy",
|
|
24
|
-
"AidotError",
|
|
25
20
|
"AidotAuthFailed",
|
|
26
21
|
"AidotAuthTokenExpired",
|
|
22
|
+
"AidotCameraBusy",
|
|
23
|
+
"AidotClient",
|
|
24
|
+
"AidotError",
|
|
27
25
|
"AidotNotLogin",
|
|
28
26
|
"AidotOSError",
|
|
29
27
|
"AidotUserOrPassIncorrect",
|
|
28
|
+
"DeviceClient",
|
|
29
|
+
"Discover",
|
|
30
30
|
"HTTPError",
|
|
31
31
|
"InvalidHost",
|
|
32
32
|
"InvalidURL",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -21,17 +21,17 @@ from .device_model import (
|
|
|
21
21
|
|
|
22
22
|
__all__ = [
|
|
23
23
|
"DeviceAck",
|
|
24
|
+
"DeviceActionPayload",
|
|
25
|
+
"DeviceActionRequest",
|
|
24
26
|
"DeviceAttr",
|
|
25
27
|
"DeviceAttrPayload",
|
|
28
|
+
"DeviceFading",
|
|
29
|
+
"DeviceInformation",
|
|
30
|
+
"DeviceProduct",
|
|
31
|
+
"DeviceProperties",
|
|
26
32
|
"DeviceResponse",
|
|
27
|
-
"DeviceActionPayload",
|
|
28
|
-
"DeviceActionRequest",
|
|
29
33
|
"LoginPayload",
|
|
30
34
|
"LoginRequest",
|
|
31
35
|
"PingRequest",
|
|
32
36
|
"PingResponse",
|
|
33
|
-
"DeviceFading",
|
|
34
|
-
"DeviceProperties",
|
|
35
|
-
"DeviceProduct",
|
|
36
|
-
"DeviceInformation",
|
|
37
37
|
]
|
|
File without changes
|
{python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/models/discover_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|