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.
Files changed (43) hide show
  1. {python_aidot_cameras-0.7.8/src/python_aidot_cameras.egg-info → python_aidot_cameras-0.7.9}/PKG-INFO +1 -1
  2. python_aidot_cameras-0.7.9/pyproject.toml +74 -0
  3. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/aes_utils.py +2 -2
  4. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/camera/__init__.py +1 -1
  5. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/camera/client.py +99 -85
  6. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/camera/lan_control.py +5 -5
  7. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/client.py +3 -3
  8. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/const.py +1 -1
  9. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/device_client.py +20 -15
  10. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/discover.py +0 -1
  11. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/models/device_client_model.py +2 -2
  12. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9/src/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
  13. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_post_merge_hardening.py +1 -2
  14. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_stream_cap.py +1 -1
  15. python_aidot_cameras-0.7.8/pyproject.toml +0 -41
  16. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/LICENSE +0 -0
  17. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/README.md +0 -0
  18. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/setup.cfg +0 -0
  19. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/__init__.py +5 -5
  20. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/credentials.py +0 -0
  21. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/exceptions.py +0 -0
  22. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/g711.py +0 -0
  23. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/login_const.py +0 -0
  24. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/models/__init__.py +6 -6
  25. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/models/device_model.py +0 -0
  26. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/aidot/models/discover_model.py +0 -0
  27. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/python_aidot_cameras.egg-info/SOURCES.txt +0 -0
  28. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
  29. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/python_aidot_cameras.egg-info/requires.txt +0 -0
  30. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/src/python_aidot_cameras.egg-info/top_level.txt +0 -0
  31. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_alarm_event.py +0 -0
  32. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_highport_nomination.py +0 -0
  33. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_lan_control.py +0 -0
  34. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_motion_poll.py +0 -0
  35. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_no_undefined_names.py +0 -0
  36. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_sdes_idle_release.py +0 -0
  37. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_sdes_sprop.py +0 -0
  38. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_sdes_talk.py +0 -0
  39. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_sdes_watchdog.py +0 -0
  40. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_speak.py +0 -0
  41. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_talk.py +0 -0
  42. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_terminal_ack.py +0 -0
  43. {python_aidot_cameras-0.7.8 → python_aidot_cameras-0.7.9}/tests/test_token_refresh.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.8
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
  """
@@ -2,4 +2,4 @@
2
2
 
3
3
  from .client import CameraMixin, WebRTCSession, SdesSession
4
4
 
5
- __all__ = ["CameraMixin", "WebRTCSession", "SdesSession"]
5
+ __all__ = ["CameraMixin", "SdesSession", "WebRTCSession"]
@@ -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 aes_encrypt, aes_decrypt, aes_ecb_encrypt_str_key, aes_ecb_decrypt_str_key
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, hashlib as _hashlib
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 asyncio.TimeoutError:
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 asyncio.TimeoutError:
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 asyncio.TimeoutError:
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, struct as _st, base64 as _b64
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), "r") as fh:
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: # noqa: BLE001
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: # noqa: BLE001 - any LAN error -> cloud fallback
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: # noqa: BLE001
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, time
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 (_asyncio.TimeoutError, _asyncio.CancelledError):
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 _asyncio.TimeoutError:
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
- asyncio.ensure_future(res)
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 asyncio.TimeoutError:
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 asyncio.TimeoutError:
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, "r") as _fh:
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 asyncio.TimeoutError:
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
- asyncio.ensure_future(_http_wake())
6150
- asyncio.ensure_future(_http_keepalive())
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 asyncio.TimeoutError:
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
- asyncio.ensure_future(_http_wake())
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 asyncio.TimeoutError:
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 asyncio.TimeoutError:
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 asyncio.TimeoutError:
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 asyncio.TimeoutError:
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
- asyncio.ensure_future(_dcep_open_then_living())
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
- asyncio.ensure_future(_suppress_audio_sender_rtcp())
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
- asyncio.ensure_future(_request_keyframe())
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' == l.rstrip() for l in s)), None)
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' == l.rstrip() for l in sec) and sec[0].startswith('m=application'):
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' == l.rstrip() for l in sec):
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, hmac as _hm, struct as _st_ta, select as _sl_ta, time as _tm_ta
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, hashlib as _hlk_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, hashlib as _hs_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 _asyncio.TimeoutError:
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 _asyncio.TimeoutError:
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, hashlib as _hlk_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 _asyncio.TimeoutError:
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, hashlib as _hlk
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, select as _select
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, random as _r_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, random as _r_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, random as _r_sc, time as _t_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, os as _os_uc, hmac as _hm_uc, hashlib as _hs_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, socket as _socket_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, select as _sel_br, time as _time_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, random as _r_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, random as _r_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)), # noqa: F821
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, random as _r_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 asyncio.TimeoutError:
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 asyncio.TimeoutError:
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
- asyncio.ensure_future(_late_second_answer_task())
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 asyncio.TimeoutError:
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: # noqa: BLE001 - undecodable / foreign packet
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 (OSError, asyncio.TimeoutError) as exc:
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 (asyncio.TimeoutError, asyncio.IncompleteReadError):
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: # noqa: BLE001
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: # noqa: BLE001
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: # noqa: BLE001
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: # noqa: BLE001
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, PUBLIC_KEY_PEM # noqa: E402 - back-compat re-export
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, object):
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 CONF_ON_OFF in dev_attr:
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}")
@@ -1,7 +1,6 @@
1
1
  import re
2
2
  import socket
3
3
  import json
4
- import time
5
4
  import logging
6
5
  import asyncio
7
6
  import subprocess
@@ -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
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot-cameras
3
- Version: 0.7.8
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
@@ -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, CameraDeviceInformation
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 asyncio.TimeoutError:
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"]
@@ -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",
@@ -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
  ]