python-aidot-cameras 0.10.3__tar.gz → 0.11.1__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.10.3/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.1}/PKG-INFO +1 -1
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/client.py +93 -10
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/lan_control.py +18 -2
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/playback.py +18 -8
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/sdes_open.py +12 -4
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/webrtc_open.py +27 -6
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/pyproject.toml +1 -1
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- python_aidot_cameras-0.11.1/tests/test_deferred_hardening.py +98 -0
- python_aidot_cameras-0.11.1/tests/test_offline_keepalive_pause.py +112 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/LICENSE +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/README.md +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/__main__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/aes_utils.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/constants.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/controls.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/models.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/protocol.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/sdes.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/tutk.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/client.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/const.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/credentials.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/device_client.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/discover.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/exceptions.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/g711.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/login_const.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/models/__init__.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/models/device_client_model.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/models/device_model.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/models/discover_model.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/py.typed +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/setup.cfg +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_wait_or_event.py +0 -0
{python_aidot_cameras-0.10.3/python_aidot_cameras.egg-info → python_aidot_cameras-0.11.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.1
|
|
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
|
|
@@ -67,6 +67,7 @@ from .protocol import ( # noqa: F401 - used here and/or by the webrtc_open mixi
|
|
|
67
67
|
_h264_has_keyframe,
|
|
68
68
|
_mqtt_session_sync,
|
|
69
69
|
_mqtt_session,
|
|
70
|
+
_mqtt_session_with_status,
|
|
70
71
|
_mqtt_get_playback_server_info,
|
|
71
72
|
_ip_looks_ascii_garbled,
|
|
72
73
|
_normalize_bundle_ice_credentials,
|
|
@@ -85,6 +86,13 @@ from .protocol import ( # noqa: F401 - re-exported for device_client / back-com
|
|
|
85
86
|
|
|
86
87
|
_LOGGER = logging.getLogger(__name__)
|
|
87
88
|
|
|
89
|
+
# Offline keepalive pause (see CameraMixin._backoff_or_offline_pause): while the
|
|
90
|
+
# cloud explicitly reports a device offline, failed-open retries re-check the
|
|
91
|
+
# flag every RECHECK seconds and only probe a real open every PROBE seconds,
|
|
92
|
+
# instead of consuming an open-gate slot on the normal reconnect cadence.
|
|
93
|
+
_OFFLINE_RECHECK_S = float(os.environ.get("AIDOT_OFFLINE_RECHECK_S", "30"))
|
|
94
|
+
_OFFLINE_PROBE_S = float(os.environ.get("AIDOT_OFFLINE_PROBE_S", "600"))
|
|
95
|
+
|
|
88
96
|
# Strong refs to fire-and-forget tasks: asyncio only keeps weak refs, so a
|
|
89
97
|
# discarded task can be garbage-collected mid-flight. Discarded on completion.
|
|
90
98
|
_BG_TASKS: set = set()
|
|
@@ -93,10 +101,34 @@ _BG_TASKS: set = set()
|
|
|
93
101
|
def _spawn_bg(coro):
|
|
94
102
|
_t = asyncio.ensure_future(coro)
|
|
95
103
|
_BG_TASKS.add(_t)
|
|
96
|
-
|
|
104
|
+
|
|
105
|
+
def _done(task):
|
|
106
|
+
_BG_TASKS.discard(task)
|
|
107
|
+
# Retrieve the exception so a failing background task doesn't surface as
|
|
108
|
+
# an unhandled "Task exception was never retrieved" at GC time; log it.
|
|
109
|
+
if not task.cancelled():
|
|
110
|
+
exc = task.exception()
|
|
111
|
+
if exc is not None:
|
|
112
|
+
_LOGGER.debug("aidot background task failed: %r", exc, exc_info=exc)
|
|
113
|
+
|
|
114
|
+
_t.add_done_callback(_done)
|
|
97
115
|
return _t
|
|
98
116
|
|
|
99
117
|
|
|
118
|
+
def _mqtt_publish_delivered(status) -> bool:
|
|
119
|
+
"""True if an MQTT command reached the broker (connection up, no error).
|
|
120
|
+
|
|
121
|
+
Distinguishes a genuine fire-and-forget publish (broker connected, the device
|
|
122
|
+
simply doesn't ACK) from a refused/failed connection - which returns an empty
|
|
123
|
+
message list too, and must NOT be reported as "sent".
|
|
124
|
+
"""
|
|
125
|
+
if not status:
|
|
126
|
+
return True # no status available: preserve prior best-effort behavior
|
|
127
|
+
if status.get("error"):
|
|
128
|
+
return False
|
|
129
|
+
return status.get("connected", True) is not False
|
|
130
|
+
|
|
131
|
+
|
|
100
132
|
def _retry_policy(failure_kind: str, burst_attempt: int, *,
|
|
101
133
|
burst_delay: float = 3.0, burst_max: int = 4,
|
|
102
134
|
base_gate: float = 15.0) -> "tuple[float, bool]":
|
|
@@ -736,8 +768,56 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
736
768
|
self.status.update_from_camera_attributes(props)
|
|
737
769
|
if device.get("online") is not None:
|
|
738
770
|
self.status.online = bool(device.get("online"))
|
|
771
|
+
# The cloud explicitly reported reachability - only then may the
|
|
772
|
+
# keepalive loops trust an offline flag (see
|
|
773
|
+
# _backoff_or_offline_pause; DeviceStatusData.online defaults to
|
|
774
|
+
# False, which must not read as "offline").
|
|
775
|
+
self._cloud_online_explicit = True
|
|
739
776
|
return self.status
|
|
740
777
|
|
|
778
|
+
async def _backoff_or_offline_pause(self, delay: float) -> None:
|
|
779
|
+
"""Sleep a reconnect backoff, extended while the device is cloud-offline.
|
|
780
|
+
|
|
781
|
+
Normal case (device online, or the cloud never reported an online
|
|
782
|
+
flag): plain ``asyncio.sleep(delay)`` - behavior unchanged.
|
|
783
|
+
|
|
784
|
+
When an open just failed AND the cloud has explicitly reported the
|
|
785
|
+
device offline (``update_status_from_device`` saw ``online: false``),
|
|
786
|
+
hold here instead of retrying on the pacer cadence: re-check the flag
|
|
787
|
+
every ``AIDOT_OFFLINE_RECHECK_S`` (default 30 s) and resume the moment
|
|
788
|
+
it flips back on, or fall through after ``AIDOT_OFFLINE_PROBE_S``
|
|
789
|
+
(default 600 s) so one probe open still runs in case the cloud flag is
|
|
790
|
+
stale. This keeps a dead/unpowered camera from consuming an open-gate
|
|
791
|
+
slot every backoff cycle and starving live cameras' cold opens
|
|
792
|
+
(observed live: two unpowered A000088s cycling 30 s open attempts
|
|
793
|
+
pushed a healthy camera's cold open past two minutes).
|
|
794
|
+
"""
|
|
795
|
+
if self.status.online or not getattr(self, "_cloud_online_explicit", False):
|
|
796
|
+
await asyncio.sleep(delay)
|
|
797
|
+
return
|
|
798
|
+
_LOGGER.info(
|
|
799
|
+
"keepalive[%s]: device is cloud-offline - pausing open retries "
|
|
800
|
+
"(recheck %.0fs, probe %.0fs)",
|
|
801
|
+
self.device_id, _OFFLINE_RECHECK_S, _OFFLINE_PROBE_S,
|
|
802
|
+
)
|
|
803
|
+
_t0 = time.monotonic()
|
|
804
|
+
while (_elapsed := time.monotonic() - _t0) < _OFFLINE_PROBE_S:
|
|
805
|
+
await asyncio.sleep(
|
|
806
|
+
min(_OFFLINE_RECHECK_S, max(0.1, _OFFLINE_PROBE_S - _elapsed))
|
|
807
|
+
)
|
|
808
|
+
if not getattr(self, "_streaming_active", False):
|
|
809
|
+
return
|
|
810
|
+
if self.status.online:
|
|
811
|
+
_LOGGER.info(
|
|
812
|
+
"keepalive[%s]: device back online - resuming opens",
|
|
813
|
+
self.device_id,
|
|
814
|
+
)
|
|
815
|
+
return
|
|
816
|
+
_LOGGER.debug(
|
|
817
|
+
"keepalive[%s]: offline probe interval elapsed - probing one open",
|
|
818
|
+
self.device_id,
|
|
819
|
+
)
|
|
820
|
+
|
|
741
821
|
# -- Camera helpers ------------------------------------------------------ #
|
|
742
822
|
|
|
743
823
|
@property
|
|
@@ -1418,14 +1498,14 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1418
1498
|
"_mqtt_device_cmd: persistent MQTT failed (%s); falling back "
|
|
1419
1499
|
"to a per-op session for %s", _st.get("error"), device_id,
|
|
1420
1500
|
)
|
|
1421
|
-
messages = await
|
|
1501
|
+
messages, _st = await _mqtt_session_with_status(
|
|
1422
1502
|
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
1423
1503
|
subscribe_topics=sub_topics,
|
|
1424
1504
|
publish_items=publish_items,
|
|
1425
1505
|
duration=timeout,
|
|
1426
1506
|
)
|
|
1427
1507
|
else:
|
|
1428
|
-
messages = await
|
|
1508
|
+
messages, _st = await _mqtt_session_with_status(
|
|
1429
1509
|
mqtt_url, mqtt_user, mqtt_pwd, client_id,
|
|
1430
1510
|
subscribe_topics=sub_topics,
|
|
1431
1511
|
publish_items=publish_items,
|
|
@@ -1449,10 +1529,13 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1449
1529
|
_LOGGER.debug("device cmd ack 200 (inner): topic=%s", topic)
|
|
1450
1530
|
return True
|
|
1451
1531
|
|
|
1452
|
-
# Fire-and-forget fallback:
|
|
1453
|
-
#
|
|
1454
|
-
#
|
|
1455
|
-
|
|
1532
|
+
# Fire-and-forget fallback: the official app uses a delivery callback,
|
|
1533
|
+
# not a device response, so a published command with no 200-ack is
|
|
1534
|
+
# normal. But only treat it as success if the broker connection actually
|
|
1535
|
+
# succeeded - a refused/failed connection returns an empty message list
|
|
1536
|
+
# too, and reporting that as "sent" makes HA show a state change that
|
|
1537
|
+
# never reached the camera.
|
|
1538
|
+
if messages is not None and _mqtt_publish_delivered(_st):
|
|
1456
1539
|
_LOGGER.debug(
|
|
1457
1540
|
"device cmd: sent (no explicit 200-ack on %s, %d msgs total) "
|
|
1458
1541
|
"- treating as sent-ok (official app is fire-and-forget)",
|
|
@@ -2532,7 +2615,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2532
2615
|
self.device_id, _delay, exc,
|
|
2533
2616
|
)
|
|
2534
2617
|
try:
|
|
2535
|
-
await
|
|
2618
|
+
await self._backoff_or_offline_pause(_delay)
|
|
2536
2619
|
except asyncio.CancelledError:
|
|
2537
2620
|
return
|
|
2538
2621
|
continue
|
|
@@ -2707,7 +2790,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2707
2790
|
self.device_id, _delay, exc,
|
|
2708
2791
|
)
|
|
2709
2792
|
try:
|
|
2710
|
-
await
|
|
2793
|
+
await self._backoff_or_offline_pause(_delay)
|
|
2711
2794
|
except asyncio.CancelledError:
|
|
2712
2795
|
return
|
|
2713
2796
|
continue
|
|
@@ -3203,7 +3286,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3203
3286
|
self.device_id, _delay, exc,
|
|
3204
3287
|
)
|
|
3205
3288
|
try:
|
|
3206
|
-
await
|
|
3289
|
+
await self._backoff_or_offline_pause(_delay)
|
|
3207
3290
|
except asyncio.CancelledError:
|
|
3208
3291
|
return
|
|
3209
3292
|
continue
|
|
@@ -274,9 +274,25 @@ class CameraLanClient:
|
|
|
274
274
|
}
|
|
275
275
|
writer.write(_pack(1, aes_encrypt(json.dumps(msg).encode(), self._key)))
|
|
276
276
|
await writer.drain()
|
|
277
|
-
|
|
278
|
-
|
|
277
|
+
try:
|
|
278
|
+
resp = json.loads(
|
|
279
|
+
aes_decrypt(await _read_frame(reader, timeout=8.0), self._key)
|
|
280
|
+
)
|
|
281
|
+
ack = (resp.get("ack") or {}).get("code")
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
# A host that can't produce a response we can decrypt with the
|
|
284
|
+
# device's real AES key is not the camera (e.g. a LAN peer that
|
|
285
|
+
# spoofed this devId in discovery - discovery is unauthenticated).
|
|
286
|
+
# Mark ineligible so control falls back to the cloud instead of
|
|
287
|
+
# repeatedly targeting a bogus/broken host.
|
|
288
|
+
self._eligible = False
|
|
289
|
+
raise CameraLanError(
|
|
290
|
+
f"{self.device_id}: login response undecryptable (wrong host?)"
|
|
291
|
+
) from exc
|
|
279
292
|
if ack != 200:
|
|
293
|
+
# The real device rejected our key-authenticated login; stop using
|
|
294
|
+
# the LAN path and revert to cloud.
|
|
295
|
+
self._eligible = False
|
|
280
296
|
raise CameraLanError(f"{self.device_id}: login rejected ack={ack}")
|
|
281
297
|
return (resp.get("payload") or {}).get("ascNumber", 1)
|
|
282
298
|
|
|
@@ -29,6 +29,7 @@ from .models import VideoFrame
|
|
|
29
29
|
_LOGGER = logging.getLogger(__name__)
|
|
30
30
|
|
|
31
31
|
_PLAYBACK_TLS_VERIFY_ENV = "AIDOT_PLAYBACK_TLS_VERIFY"
|
|
32
|
+
_PLAYBACK_TLS_WARNED = False # module-level once-guard for the insecure-default warning
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
def _playback_ssl_context() -> ssl.SSLContext:
|
|
@@ -48,13 +49,16 @@ def _playback_ssl_context() -> ssl.SSLContext:
|
|
|
48
49
|
else:
|
|
49
50
|
ctx.check_hostname = False
|
|
50
51
|
ctx.verify_mode = ssl.CERT_NONE
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
global _PLAYBACK_TLS_WARNED
|
|
53
|
+
if not _PLAYBACK_TLS_WARNED:
|
|
54
|
+
_PLAYBACK_TLS_WARNED = True
|
|
55
|
+
_LOGGER.warning(
|
|
56
|
+
"playback: TLS certificate verification is DISABLED; the "
|
|
57
|
+
"live/playback stream is not authenticated and is exposed to "
|
|
58
|
+
"on-path tampering. Set %s=1 to enable verification once a trust "
|
|
59
|
+
"anchor for the camera's certificate is available. (logged once)",
|
|
60
|
+
_PLAYBACK_TLS_VERIFY_ENV,
|
|
61
|
+
)
|
|
58
62
|
return ctx
|
|
59
63
|
|
|
60
64
|
|
|
@@ -140,6 +144,7 @@ class CloudPlaybackSession:
|
|
|
140
144
|
client_id: str,
|
|
141
145
|
start_ts_s: int,
|
|
142
146
|
on_frame: Callable[[VideoFrame], None],
|
|
147
|
+
use_tls: bool = False,
|
|
143
148
|
) -> None:
|
|
144
149
|
self._server_ip = server_ip
|
|
145
150
|
self._server_port = server_port
|
|
@@ -148,6 +153,10 @@ class CloudPlaybackSession:
|
|
|
148
153
|
self._client_id = client_id
|
|
149
154
|
self._start_ts = start_ts_s
|
|
150
155
|
self._on_frame = on_frame
|
|
156
|
+
# Opt-in TLS for the cloud-playback TCP stream. Default False preserves
|
|
157
|
+
# the historical plaintext behavior (the cloud playback server may not
|
|
158
|
+
# speak TLS on this port); pass True once verified against a live server.
|
|
159
|
+
self._use_tls = use_tls
|
|
151
160
|
self._reader: Optional[asyncio.StreamReader] = None
|
|
152
161
|
self._writer: Optional[asyncio.StreamWriter] = None
|
|
153
162
|
self._running = False
|
|
@@ -157,8 +166,9 @@ class CloudPlaybackSession:
|
|
|
157
166
|
|
|
158
167
|
async def _connect_and_login(self) -> bool:
|
|
159
168
|
try:
|
|
169
|
+
ssl_ctx = _playback_ssl_context() if self._use_tls else None
|
|
160
170
|
self._reader, self._writer = await asyncio.open_connection(
|
|
161
|
-
self._server_ip, self._server_port
|
|
171
|
+
self._server_ip, self._server_port, ssl=ssl_ctx
|
|
162
172
|
)
|
|
163
173
|
except OSError as exc:
|
|
164
174
|
_LOGGER.error(
|
|
@@ -3174,16 +3174,24 @@ class _SdesOpenMixin:
|
|
|
3174
3174
|
# refresh (RFC 7675). Also handles the case where the initial
|
|
3175
3175
|
# USE-CANDIDATE (sent right after the STUN window) was lost.
|
|
3176
3176
|
_br_now = _time_br.monotonic()
|
|
3177
|
-
|
|
3177
|
+
# Relay-only battery cams (LAN IP unknown) answer AFTER the STUN
|
|
3178
|
+
# window and send no probes, so the in-window proactive send saw
|
|
3179
|
+
# empty _cam_ice_* and the probe-gated late-send above never runs.
|
|
3180
|
+
# Fall back to the creds parsed late into _bridge_uc_info so this
|
|
3181
|
+
# ungated periodic tick still nominates them. [SDES-LATECREDS-FIX]
|
|
3182
|
+
_uc_cands = _cam_ice_cands or _bridge_uc_info.get("cands")
|
|
3183
|
+
_uc_cufrag = _cam_ice_ufrag or _bridge_uc_info.get("ufrag")
|
|
3184
|
+
_uc_cpwd = _cam_ice_pwd or _bridge_uc_info.get("pwd")
|
|
3185
|
+
if _uc_cands and _uc_cufrag and _uc_cpwd and (_br_now - _br_last_uc) >= 2.5:
|
|
3178
3186
|
_br_last_uc = _br_now
|
|
3179
|
-
for _c_ip, _c_port in
|
|
3187
|
+
for _c_ip, _c_port in _uc_cands:
|
|
3180
3188
|
_send_use_candidate(
|
|
3181
3189
|
_audio_sock, _ufrag_a, _pwd_a,
|
|
3182
|
-
|
|
3190
|
+
_uc_cufrag, _uc_cpwd, (_c_ip, _c_port),
|
|
3183
3191
|
)
|
|
3184
3192
|
_send_use_candidate(
|
|
3185
3193
|
_video_sock, _ufrag_v, _pwd_v,
|
|
3186
|
-
|
|
3194
|
+
_uc_cufrag, _uc_cpwd, (_c_ip, _c_port),
|
|
3187
3195
|
)
|
|
3188
3196
|
finally:
|
|
3189
3197
|
try:
|
|
@@ -356,12 +356,20 @@ class _WebRTCOpenMixin:
|
|
|
356
356
|
self._key
|
|
357
357
|
)
|
|
358
358
|
_ctx = _orig_create_ctx(self, srtp_profiles)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
359
|
+
# Self-scoped: only lower the DTLS floor for certificates
|
|
360
|
+
# WE tagged for an aidot camera session (tagged right
|
|
361
|
+
# after RTCPeerConnection creation below). Other aiortc
|
|
362
|
+
# peer connections in the same process (e.g. another Home
|
|
363
|
+
# Assistant integration) use untagged certs and keep the
|
|
364
|
+
# modern DTLS 1.2 floor - the class-level patch is now a
|
|
365
|
+
# no-op for them.
|
|
366
|
+
if getattr(self, "_aidot_dtls10", False):
|
|
367
|
+
try:
|
|
368
|
+
_ctx.set_min_proto_version(_DTLS1_VERSION)
|
|
369
|
+
except Exception as _e:
|
|
370
|
+
_LOGGER.warning(
|
|
371
|
+
"DTLS 1.0 enable failed: %s", _e
|
|
372
|
+
)
|
|
365
373
|
return _ctx
|
|
366
374
|
_AidotRTCCert._create_ssl_context = _aidot_create_ssl_context
|
|
367
375
|
_AidotRTCCert._aidot_dtls10_patched = True
|
|
@@ -1465,6 +1473,19 @@ class _WebRTCOpenMixin:
|
|
|
1465
1473
|
pc = RTCPeerConnection(
|
|
1466
1474
|
configuration=RTCConfiguration(iceServers=_pc_ice_servers)
|
|
1467
1475
|
)
|
|
1476
|
+
# Tag THIS pc's DTLS certificate(s) so the self-scoped DTLS-1.0 floor
|
|
1477
|
+
# (see _aidot_create_ssl_context) applies only to aidot camera sessions,
|
|
1478
|
+
# not to other aiortc peer connections in the same process. Best-effort:
|
|
1479
|
+
# if aiortc's internal layout changes, the tag is simply absent and the
|
|
1480
|
+
# session keeps the default DTLS 1.2 floor.
|
|
1481
|
+
try:
|
|
1482
|
+
for _aidot_cert in (
|
|
1483
|
+
getattr(pc, "_RTCPeerConnection__certificates", None) or []
|
|
1484
|
+
):
|
|
1485
|
+
_aidot_cert._aidot_dtls10 = True
|
|
1486
|
+
except Exception:
|
|
1487
|
+
_LOGGER.debug("camera %s: could not tag DTLS cert for 1.0 scope",
|
|
1488
|
+
getattr(self, "device_id", "?"), exc_info=True)
|
|
1468
1489
|
# Audio: sendrecv WITHOUT a real audio sender. Empirical findings
|
|
1469
1490
|
# from 2026-04-26 testing (commits aa341a1b, aeaea893):
|
|
1470
1491
|
#
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.1"
|
|
8
8
|
description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1/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.
|
|
3
|
+
Version: 0.11.1
|
|
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
|
|
@@ -74,6 +74,7 @@ python_aidot_cameras.egg-info/top_level.txt
|
|
|
74
74
|
tests/test_aioice_compat.py
|
|
75
75
|
tests/test_alarm_event.py
|
|
76
76
|
tests/test_backoff.py
|
|
77
|
+
tests/test_deferred_hardening.py
|
|
77
78
|
tests/test_device_login_guard.py
|
|
78
79
|
tests/test_device_user_info_cache.py
|
|
79
80
|
tests/test_dtls_not_ready_burst.py
|
|
@@ -90,6 +91,7 @@ tests/test_live_stream_param.py
|
|
|
90
91
|
tests/test_motion_poll.py
|
|
91
92
|
tests/test_narrow_pc_ice.py
|
|
92
93
|
tests/test_no_undefined_names.py
|
|
94
|
+
tests/test_offline_keepalive_pause.py
|
|
93
95
|
tests/test_open_gate_delay.py
|
|
94
96
|
tests/test_persistent_mqtt.py
|
|
95
97
|
tests/test_persistent_mqtt_robustness.py
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tests for the deferred security-review hardening (prep branch).
|
|
2
|
+
|
|
3
|
+
Covers the parts that don't need real cameras. The transport-level behaviours
|
|
4
|
+
(self-scoped DTLS 1.0, cloud-playback TLS on the wire) need on-device validation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import struct
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from aidot.aes_utils import aes_encrypt
|
|
14
|
+
from aidot.camera.client import _mqtt_publish_delivered
|
|
15
|
+
from aidot.camera.lan_control import _MAGIC, CameraLanClient, CameraLanError
|
|
16
|
+
from aidot.camera.playback import CloudPlaybackSession
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# -- _mqtt_device_cmd no longer reports success on a failed broker connection --
|
|
20
|
+
|
|
21
|
+
def test_mqtt_publish_delivered_semantics():
|
|
22
|
+
assert _mqtt_publish_delivered(None) is True # no status: prior behavior
|
|
23
|
+
assert _mqtt_publish_delivered({}) is True # connected assumed
|
|
24
|
+
assert _mqtt_publish_delivered({"connected": True}) is True
|
|
25
|
+
assert _mqtt_publish_delivered({"connected": False}) is False # broker refused
|
|
26
|
+
assert _mqtt_publish_delivered({"error": "timeout"}) is False # connect error
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# -- CloudPlaybackSession TLS is opt-in, default off ---------------------------
|
|
30
|
+
|
|
31
|
+
def _cloud_session(use_tls):
|
|
32
|
+
return CloudPlaybackSession(
|
|
33
|
+
server_ip="10.0.0.5", server_port=443, heartbeat_interval=10,
|
|
34
|
+
task_id=1, client_id="c", start_ts_s=0, on_frame=lambda f: None,
|
|
35
|
+
use_tls=use_tls,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_cloud_playback_tls_defaults_off():
|
|
40
|
+
assert _cloud_session(False)._use_tls is False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_cloud_playback_tls_opt_in():
|
|
44
|
+
assert _cloud_session(True)._use_tls is True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# -- LAN control: a login we can't complete de-eligibilities the client --------
|
|
48
|
+
|
|
49
|
+
class _FakeWriter:
|
|
50
|
+
def write(self, _data):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
async def drain(self):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _frame(body: bytes) -> bytes:
|
|
58
|
+
return struct.pack(">HHI", _MAGIC, 2, len(body)) + body
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _lan_client():
|
|
62
|
+
return CameraLanClient(
|
|
63
|
+
{"id": "cam1", "aesKey": ["0123456789abcdef"], "password": "pw",
|
|
64
|
+
"modelId": "LK.IPC.A000088"},
|
|
65
|
+
{"id": "user1"},
|
|
66
|
+
ip="10.0.0.9",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reader_with(body: bytes) -> asyncio.StreamReader:
|
|
71
|
+
r = asyncio.StreamReader()
|
|
72
|
+
r.feed_data(_frame(body))
|
|
73
|
+
r.feed_eof()
|
|
74
|
+
return r
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_login_rejection_marks_ineligible():
|
|
78
|
+
c = _lan_client()
|
|
79
|
+
resp = aes_encrypt(json.dumps({"ack": {"code": 403}}).encode(), c._key)
|
|
80
|
+
|
|
81
|
+
async def _run():
|
|
82
|
+
with pytest.raises(CameraLanError, match="rejected"):
|
|
83
|
+
await c._login(_reader_with(resp), _FakeWriter())
|
|
84
|
+
|
|
85
|
+
asyncio.run(_run())
|
|
86
|
+
assert c.eligible is False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_undecryptable_login_response_marks_ineligible():
|
|
90
|
+
# A spoofing host that lacks the device key returns bytes we can't decrypt.
|
|
91
|
+
c = _lan_client()
|
|
92
|
+
|
|
93
|
+
async def _run():
|
|
94
|
+
with pytest.raises(CameraLanError, match="undecryptable"):
|
|
95
|
+
await c._login(_reader_with(b"not-valid-aes-ciphertext-xxxxxxx"), _FakeWriter())
|
|
96
|
+
|
|
97
|
+
asyncio.run(_run())
|
|
98
|
+
assert c.eligible is False
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Offline keepalive pause: failed-open retries hold while the device is
|
|
2
|
+
cloud-offline (CameraMixin._backoff_or_offline_pause) instead of consuming an
|
|
3
|
+
open-gate slot on the normal reconnect cadence."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
|
|
9
|
+
import aidot.camera.client as camera_client
|
|
10
|
+
from aidot.camera.client import CameraMixin
|
|
11
|
+
from aidot.device_client import DeviceStatusData
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _stub(online: bool, explicit: bool, streaming: bool = True) -> SimpleNamespace:
|
|
15
|
+
s = SimpleNamespace(
|
|
16
|
+
status=DeviceStatusData(),
|
|
17
|
+
device_id="test-device",
|
|
18
|
+
_streaming_active=streaming,
|
|
19
|
+
)
|
|
20
|
+
s.status.online = online
|
|
21
|
+
if explicit:
|
|
22
|
+
s._cloud_online_explicit = True
|
|
23
|
+
return s
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run(coro):
|
|
27
|
+
return asyncio.run(coro)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_online_device_sleeps_plain_delay():
|
|
31
|
+
stub = _stub(online=True, explicit=True)
|
|
32
|
+
t0 = time.monotonic()
|
|
33
|
+
_run(CameraMixin._backoff_or_offline_pause(stub, 0.05))
|
|
34
|
+
took = time.monotonic() - t0
|
|
35
|
+
assert 0.04 <= took < 1.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_unreported_online_state_never_pauses(monkeypatch):
|
|
39
|
+
# DeviceStatusData.online defaults to False; without the explicit marker
|
|
40
|
+
# that must NOT read as "offline" (a long pause here would slow every
|
|
41
|
+
# camera whose device dict was never merged).
|
|
42
|
+
monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 60.0)
|
|
43
|
+
stub = _stub(online=False, explicit=False)
|
|
44
|
+
t0 = time.monotonic()
|
|
45
|
+
_run(CameraMixin._backoff_or_offline_pause(stub, 0.05))
|
|
46
|
+
took = time.monotonic() - t0
|
|
47
|
+
assert took < 1.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_offline_device_holds_then_probes(monkeypatch):
|
|
51
|
+
# Explicitly offline: the pause holds past the plain delay and falls
|
|
52
|
+
# through after the probe interval so one probe open still runs.
|
|
53
|
+
monkeypatch.setattr(camera_client, "_OFFLINE_RECHECK_S", 0.05)
|
|
54
|
+
monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 0.3)
|
|
55
|
+
stub = _stub(online=False, explicit=True)
|
|
56
|
+
t0 = time.monotonic()
|
|
57
|
+
_run(CameraMixin._backoff_or_offline_pause(stub, 0.01))
|
|
58
|
+
took = time.monotonic() - t0
|
|
59
|
+
assert took >= 0.3 # held for the whole probe interval
|
|
60
|
+
assert took < 2.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_offline_device_resumes_when_back_online(monkeypatch):
|
|
64
|
+
monkeypatch.setattr(camera_client, "_OFFLINE_RECHECK_S", 0.05)
|
|
65
|
+
monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 30.0)
|
|
66
|
+
stub = _stub(online=False, explicit=True)
|
|
67
|
+
|
|
68
|
+
async def scenario():
|
|
69
|
+
async def flip():
|
|
70
|
+
await asyncio.sleep(0.15)
|
|
71
|
+
stub.status.online = True
|
|
72
|
+
|
|
73
|
+
t0 = time.monotonic()
|
|
74
|
+
await asyncio.gather(
|
|
75
|
+
CameraMixin._backoff_or_offline_pause(stub, 0.01), flip()
|
|
76
|
+
)
|
|
77
|
+
return time.monotonic() - t0
|
|
78
|
+
|
|
79
|
+
took = _run(scenario())
|
|
80
|
+
# resumed within a couple of recheck ticks of the flip, far short of PROBE
|
|
81
|
+
assert 0.15 <= took < 2.0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_offline_pause_exits_when_streaming_stops(monkeypatch):
|
|
85
|
+
monkeypatch.setattr(camera_client, "_OFFLINE_RECHECK_S", 0.05)
|
|
86
|
+
monkeypatch.setattr(camera_client, "_OFFLINE_PROBE_S", 30.0)
|
|
87
|
+
stub = _stub(online=False, explicit=True)
|
|
88
|
+
|
|
89
|
+
async def scenario():
|
|
90
|
+
async def stop():
|
|
91
|
+
await asyncio.sleep(0.15)
|
|
92
|
+
stub._streaming_active = False
|
|
93
|
+
|
|
94
|
+
t0 = time.monotonic()
|
|
95
|
+
await asyncio.gather(
|
|
96
|
+
CameraMixin._backoff_or_offline_pause(stub, 0.01), stop()
|
|
97
|
+
)
|
|
98
|
+
return time.monotonic() - t0
|
|
99
|
+
|
|
100
|
+
took = _run(scenario())
|
|
101
|
+
assert took < 2.0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_update_status_sets_explicit_marker():
|
|
105
|
+
stub = SimpleNamespace(status=DeviceStatusData())
|
|
106
|
+
CameraMixin.update_status_from_device(stub, {"online": False})
|
|
107
|
+
assert stub.status.online is False
|
|
108
|
+
assert getattr(stub, "_cloud_online_explicit", False) is True
|
|
109
|
+
|
|
110
|
+
stub2 = SimpleNamespace(status=DeviceStatusData())
|
|
111
|
+
CameraMixin.update_status_from_device(stub2, {"name": "no online key"})
|
|
112
|
+
assert getattr(stub2, "_cloud_online_explicit", False) is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/__init__.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/base.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/g711.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/g722.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/h264.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/opus.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/codecs/vpx.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/contrib/__init__.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/contrib/media.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/exceptions.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/jitterbuffer.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/mediastreams.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcconfiguration.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcdatachannel.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcdtlstransport.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcicetransport.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpparameters.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpreceiver.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcrtpsender.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/_vendor/aiortc/rtcsctptransport.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/aidot/models/device_client_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
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_device_login_guard.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_device_user_info_cache.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_dtls_not_ready_burst.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_dtls_skip_signaling_wait.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_highport_nomination.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_no_undefined_names.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_persistent_mqtt_robustness.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_post_merge_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_echo_wait_timeout.py
RENAMED
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_sdes_fast_liveplay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_aidot_cameras-0.10.3 → python_aidot_cameras-0.11.1}/tests/test_security_hardening.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
|