python-aidot-cameras 0.12.17__tar.gz → 0.13.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.12.17/python_aidot_cameras.egg-info → python_aidot_cameras-0.13.1}/PKG-INFO +2 -2
- python_aidot_cameras-0.13.1/aidot_cameras/_upstream.py +396 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/client.py +70 -18
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/sdes_open.py +79 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/webrtc_open.py +1 -1
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/client.py +243 -65
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/const.py +16 -3
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/crypto.py +12 -6
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/device_client.py +90 -24
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/discover.py +36 -12
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/pyproject.toml +15 -4
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1/python_aidot_cameras.egg-info}/PKG-INFO +2 -2
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/SOURCES.txt +6 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/requires.txt +1 -1
- python_aidot_cameras-0.13.1/tests/test_constants_imports_resolve.py +62 -0
- python_aidot_cameras-0.13.1/tests/test_ice_peer_reflexive.py +92 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_light_active_color_mode.py +4 -6
- python_aidot_cameras-0.13.1/tests/test_live_validate_policy.py +281 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_post_merge_hardening.py +37 -20
- python_aidot_cameras-0.13.1/tests/test_shared_house_seam.py +98 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_token_refresh.py +34 -20
- python_aidot_cameras-0.13.1/tests/test_upstream_compat.py +568 -0
- python_aidot_cameras-0.13.1/tests/test_viewer_present_push_mode.py +236 -0
- python_aidot_cameras-0.12.17/tests/test_upstream_compat.py +0 -268
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/LICENSE +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/README.md +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/constants.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/protocol.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/sdes.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/setup.cfg +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_battery_detection.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_battery_skip_turn.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dormant_camera_cleanup.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_mux_terminal_write.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_peerid_reuse.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_terminal_ack.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_sdp_payload_type_narrowing.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_turn_create_permission.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_wait_or_event.py +0 -0
- {python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/tests/test_wake_readiness_retry.py +0 -0
{python_aidot_cameras-0.12.17/python_aidot_cameras.egg-info → python_aidot_cameras-0.13.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.13.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
|
|
@@ -11,7 +11,7 @@ Classifier: Operating System :: OS Independent
|
|
|
11
11
|
Requires-Python: >=3.11
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
License-File: LICENSE
|
|
14
|
-
Requires-Dist: python-aidot
|
|
14
|
+
Requires-Dist: python-aidot<0.4,>=0.3.55
|
|
15
15
|
Requires-Dist: aiohttp>=3.9
|
|
16
16
|
Requires-Dist: paho-mqtt>=1.6.1
|
|
17
17
|
Requires-Dist: cryptography>=42.0
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""One place that knows which upstream ``python-aidot`` is installed.
|
|
2
|
+
|
|
3
|
+
Upstream ships two incompatible shapes of the same private API, and both are
|
|
4
|
+
live: Home Assistant core pins ``0.3.56`` while this library was written against
|
|
5
|
+
``0.3.55``. They are not a forward/backward pair - ``0.3.56`` is a **revert** of
|
|
6
|
+
the refactor that ``0.3.54`` introduced and ``0.3.55`` carried, so the "old"
|
|
7
|
+
shape is also the current one:
|
|
8
|
+
|
|
9
|
+
0.3.53 dict-based client, ``aes_utils``, ``login_const`` <- shape A
|
|
10
|
+
0.3.54 typed dataclasses, ``api/``, ``utils/crypto`` <- shape B
|
|
11
|
+
0.3.55 shape B
|
|
12
|
+
0.3.56 shape A again (plus the ``models/`` package)
|
|
13
|
+
|
|
14
|
+
Because upstream has flip-flopped once, neither shape can be assumed permanent,
|
|
15
|
+
so both are supported rather than tracked. Every difference is resolved here,
|
|
16
|
+
once, at import time; no other module in this package may branch on the upstream
|
|
17
|
+
version. If you find yourself adding a second ``try: import aidot...`` anywhere
|
|
18
|
+
else, put it here instead.
|
|
19
|
+
|
|
20
|
+
The detection is by capability (does the name import?), never by parsing a
|
|
21
|
+
version string: upstream shipped shape A under both ``0.3.53`` and ``0.3.56``
|
|
22
|
+
with a five-day excursion in between, so a version comparison would encode the
|
|
23
|
+
excursion rather than the shape.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from enum import IntEnum
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
# --------------------------------------------------------------------------- #
|
|
30
|
+
# Shape detection
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
|
|
33
|
+
try: # shape B (0.3.54 - 0.3.55): typed account/device dataclasses + CloudApi
|
|
34
|
+
from aidot.models.auth_model import UserInformation as _UserInformation
|
|
35
|
+
HAS_TYPED_ACCOUNT = True
|
|
36
|
+
except ImportError: # shape A (<=0.3.53, >=0.3.56): plain dicts
|
|
37
|
+
_UserInformation = None
|
|
38
|
+
HAS_TYPED_ACCOUNT = False
|
|
39
|
+
|
|
40
|
+
#: True when upstream exposes the typed account layer (``user_info``,
|
|
41
|
+
#: ``_cloud_api``, ``UserInformation``). Prefer the named helpers below to
|
|
42
|
+
#: branching on this directly.
|
|
43
|
+
UPSTREAM_SHAPE = "typed" if HAS_TYPED_ACCOUNT else "dict"
|
|
44
|
+
|
|
45
|
+
#: Upstream's account record class, or ``None`` on shape A where the account is
|
|
46
|
+
#: a plain dict. Only used as a type annotation and an isinstance guard.
|
|
47
|
+
UserInformation = _UserInformation
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
# Constants: four moved from ``aidot.const`` to ``aidot.login_const``
|
|
52
|
+
# --------------------------------------------------------------------------- #
|
|
53
|
+
|
|
54
|
+
try: # shape B
|
|
55
|
+
from aidot.const import (
|
|
56
|
+
API_URL_TEMPLATE,
|
|
57
|
+
APP_ID,
|
|
58
|
+
DEFAULT_REGION,
|
|
59
|
+
PUBLIC_KEY_PEM,
|
|
60
|
+
)
|
|
61
|
+
except ImportError: # shape A
|
|
62
|
+
from aidot.login_const import (
|
|
63
|
+
API_URL_TEMPLATE,
|
|
64
|
+
APP_ID,
|
|
65
|
+
DEFAULT_REGION,
|
|
66
|
+
PUBLIC_KEY_PEM,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------- #
|
|
71
|
+
# Crypto: ``aidot.utils.crypto`` -> ``aidot.aes_utils``
|
|
72
|
+
# --------------------------------------------------------------------------- #
|
|
73
|
+
|
|
74
|
+
try: # shape B
|
|
75
|
+
from aidot.utils.crypto import (
|
|
76
|
+
aes_decrypt,
|
|
77
|
+
aes_decrypt_to_json,
|
|
78
|
+
aes_encrypt,
|
|
79
|
+
)
|
|
80
|
+
except ImportError: # shape A
|
|
81
|
+
from aidot.aes_utils import (
|
|
82
|
+
aes_decrypt,
|
|
83
|
+
aes_decrypt_to_json,
|
|
84
|
+
aes_encrypt,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def rsa_encrypt(message: str, public_key: Any = None) -> str:
|
|
89
|
+
"""RSA/PKCS1v15 encrypt ``message``, returned base64.
|
|
90
|
+
|
|
91
|
+
Implemented here rather than re-exported. Shape B had
|
|
92
|
+
``aidot.utils.crypto.rsa_encrypt(message, public_key)``; shape A replaced it
|
|
93
|
+
with ``aidot.client.rsa_password_encrypt(message)``, which bakes the key in
|
|
94
|
+
and takes one argument. Neither signature is available on both shapes, and
|
|
95
|
+
``aidot_cameras.crypto.rsa_encrypt`` is public surface the integration repo
|
|
96
|
+
may import (see the back-compat rule in CLAUDE.md), so the two-argument form
|
|
97
|
+
is kept and satisfied locally. ``public_key`` defaults to upstream's
|
|
98
|
+
``PUBLIC_KEY_PEM``, which is the only key any caller ever passed.
|
|
99
|
+
"""
|
|
100
|
+
from cryptography.hazmat.backends import default_backend
|
|
101
|
+
from cryptography.hazmat.primitives import serialization
|
|
102
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
103
|
+
import base64
|
|
104
|
+
|
|
105
|
+
if public_key is None:
|
|
106
|
+
public_key = PUBLIC_KEY_PEM
|
|
107
|
+
loaded = serialization.load_pem_public_key(
|
|
108
|
+
public_key.encode() if isinstance(public_key, str) else public_key,
|
|
109
|
+
backend=default_backend(),
|
|
110
|
+
)
|
|
111
|
+
encrypted = loaded.encrypt(message.encode("utf-8"), padding.PKCS1v15())
|
|
112
|
+
return base64.b64encode(encrypted).decode("utf-8")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --------------------------------------------------------------------------- #
|
|
116
|
+
# Device record: ``DeviceModel`` (shape B) / ``DeviceInformation`` (shape A)
|
|
117
|
+
# --------------------------------------------------------------------------- #
|
|
118
|
+
|
|
119
|
+
try: # shape B
|
|
120
|
+
from aidot.models.device_model import DeviceModel as DeviceRecord
|
|
121
|
+
except ImportError: # shape A
|
|
122
|
+
from aidot.models.device_model import DeviceInformation as DeviceRecord
|
|
123
|
+
|
|
124
|
+
#: The typed cloud device record. Both shapes expose ``from_json`` /
|
|
125
|
+
#: ``to_dict`` and the core field set this package reads (``id``, ``modelId``,
|
|
126
|
+
#: ``aesKey``, ``password``, ``product``); shape A drops ~20 fields shape B
|
|
127
|
+
#: carried and defaults everything to ``None`` rather than ``""``/empty, so
|
|
128
|
+
#: never rely on a nested default being present - check for ``None`` first.
|
|
129
|
+
DeviceModel = DeviceRecord
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# --------------------------------------------------------------------------- #
|
|
133
|
+
# DeviceState: upstream's LAN session enum, deleted in shape A
|
|
134
|
+
# --------------------------------------------------------------------------- #
|
|
135
|
+
|
|
136
|
+
try: # shape B
|
|
137
|
+
from aidot.device_client import DeviceState as _DeviceState
|
|
138
|
+
DEVICE_STATE_IS_UPSTREAMS = True
|
|
139
|
+
except ImportError: # shape A: no state machine, just two booleans
|
|
140
|
+
DEVICE_STATE_IS_UPSTREAMS = False
|
|
141
|
+
|
|
142
|
+
class _DeviceState(IntEnum):
|
|
143
|
+
"""Stand-in for upstream's deleted ``DeviceState``.
|
|
144
|
+
|
|
145
|
+
Shape A tracks the LAN session with ``_connecting`` /
|
|
146
|
+
``_connect_and_login`` booleans instead of an enum. The enum is still
|
|
147
|
+
re-exported from ``aidot_cameras.device_client`` because the Home
|
|
148
|
+
Assistant integration annotates and compares against it, and dropping a
|
|
149
|
+
public name outright would break it at import.
|
|
150
|
+
|
|
151
|
+
Members and values are copied from 0.3.55 so a stored or hard-coded
|
|
152
|
+
comparison keeps its meaning. On shape A nothing upstream ever
|
|
153
|
+
*produces* one of these - use ``device_session_authenticated()`` rather
|
|
154
|
+
than comparing a client's private state against it.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
IDLE = 0
|
|
158
|
+
INITIALIZING = 1
|
|
159
|
+
CONNECTING = 2
|
|
160
|
+
CONNECTED = 3
|
|
161
|
+
CONNECTION_FAILED = 4
|
|
162
|
+
AUTHENTICATING = 5
|
|
163
|
+
AUTHENTICATED = 6
|
|
164
|
+
AUTHENTICATION_FAILED = 7
|
|
165
|
+
DISCONNECTED = 8
|
|
166
|
+
|
|
167
|
+
DeviceState = _DeviceState
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def device_session_authenticated(device_client: Any) -> bool:
|
|
171
|
+
"""Whether a device client's LAN session is logged in, on either shape.
|
|
172
|
+
|
|
173
|
+
Shape B exposes ``_state == DeviceState.AUTHENTICATED``; shape A replaced
|
|
174
|
+
that with the ``connect_and_login`` property. Consumers that only want the
|
|
175
|
+
answer should call this instead of touching either private attribute.
|
|
176
|
+
"""
|
|
177
|
+
if DEVICE_STATE_IS_UPSTREAMS:
|
|
178
|
+
return getattr(device_client, "_state", None) == DeviceState.AUTHENTICATED
|
|
179
|
+
return bool(getattr(device_client, "connect_and_login", False))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --------------------------------------------------------------------------- #
|
|
183
|
+
# Reconnect timer handle: ``_reconnect_timer`` -> ``_reconnect_handle``
|
|
184
|
+
# --------------------------------------------------------------------------- #
|
|
185
|
+
|
|
186
|
+
#: Attribute names a pending reconnect may be parked under, newest first.
|
|
187
|
+
#: ``CameraClient.async_close`` cancels whichever exists so a closed account
|
|
188
|
+
#: cannot re-open a TCP connection ~45 s later (upstream cancels neither).
|
|
189
|
+
RECONNECT_HANDLE_ATTRS = ("_reconnect_handle", "_reconnect_timer")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def cancel_pending_reconnect(device_client: Any) -> bool:
|
|
193
|
+
"""Cancel a device client's pending reconnect, whichever shape it uses.
|
|
194
|
+
|
|
195
|
+
Returns True if something was cancelled. Deliberately not a silent
|
|
196
|
+
``getattr(x, "_reconnect_timer", None)``: that spelling exists only on shape
|
|
197
|
+
B, so on shape A it would find nothing, cancel nothing, and report success -
|
|
198
|
+
the leak fix would go quiet instead of failing. Callers that care should
|
|
199
|
+
log the False case.
|
|
200
|
+
"""
|
|
201
|
+
for attr in RECONNECT_HANDLE_ATTRS:
|
|
202
|
+
handle = getattr(device_client, attr, None)
|
|
203
|
+
if handle is not None:
|
|
204
|
+
handle.cancel()
|
|
205
|
+
return True
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --------------------------------------------------------------------------- #
|
|
210
|
+
# Account HTTP surface: ``CloudApi`` (shape B) -> client methods (shape A)
|
|
211
|
+
# --------------------------------------------------------------------------- #
|
|
212
|
+
|
|
213
|
+
async def api_get_houses(client: Any) -> Optional[list]:
|
|
214
|
+
"""List the account's houses."""
|
|
215
|
+
if HAS_TYPED_ACCOUNT:
|
|
216
|
+
return await client._cloud_api.get_houses()
|
|
217
|
+
return await client.async_get_houses()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def api_get_devices(client: Any, house_id: str) -> Optional[list]:
|
|
221
|
+
"""List one house's devices."""
|
|
222
|
+
if HAS_TYPED_ACCOUNT:
|
|
223
|
+
return await client._cloud_api.get_devices(house_id)
|
|
224
|
+
return await client.async_get_devices(house_id)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def api_get_products(client: Any, product_ids: str) -> Optional[list]:
|
|
228
|
+
"""Fetch product metadata for a comma-joined id list."""
|
|
229
|
+
if HAS_TYPED_ACCOUNT:
|
|
230
|
+
return await client._cloud_api.get_products(product_ids)
|
|
231
|
+
return await client.async_get_products(product_ids)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def api_refresh_token(client: Any) -> Any:
|
|
235
|
+
"""Exchange the refresh token for a new access token.
|
|
236
|
+
|
|
237
|
+
Both shapes return the response body and raise ``AidotAuthFailed`` when the
|
|
238
|
+
refresh token itself is rejected; shape B additionally returns ``None`` on
|
|
239
|
+
some non-auth failures, so callers must treat a falsy return as "no token".
|
|
240
|
+
"""
|
|
241
|
+
if HAS_TYPED_ACCOUNT:
|
|
242
|
+
return await client._cloud_api.refresh_token()
|
|
243
|
+
return await client.async_refresh_token()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# --------------------------------------------------------------------------- #
|
|
247
|
+
# Account fields: ``user_info`` dataclass (shape B) -> plain attrs (shape A)
|
|
248
|
+
# --------------------------------------------------------------------------- #
|
|
249
|
+
|
|
250
|
+
def account_region(client: Any) -> str:
|
|
251
|
+
"""The account's API region ("us", "eu", ...)."""
|
|
252
|
+
if HAS_TYPED_ACCOUNT:
|
|
253
|
+
return getattr(client.user_info, "region", "") or ""
|
|
254
|
+
return getattr(client, "_region", "") or ""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def account_refresh_token(client: Any) -> str:
|
|
258
|
+
"""The stored refresh token, or "" when there is none."""
|
|
259
|
+
if HAS_TYPED_ACCOUNT:
|
|
260
|
+
return getattr(client.user_info, "refreshToken", "") or ""
|
|
261
|
+
from .const import CONF_REFRESH_TOKEN
|
|
262
|
+
return (client.login_info or {}).get(CONF_REFRESH_TOKEN) or ""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def account_token_ttl(client: Any, default: int = 7200) -> int:
|
|
266
|
+
"""Access-token lifetime in seconds, defaulting to the server's 7200."""
|
|
267
|
+
if HAS_TYPED_ACCOUNT:
|
|
268
|
+
raw = getattr(client.user_info, "expiresIn", 0)
|
|
269
|
+
else:
|
|
270
|
+
raw = (client.login_info or {}).get("expiresIn", 0)
|
|
271
|
+
try:
|
|
272
|
+
return int(raw or default)
|
|
273
|
+
except (TypeError, ValueError):
|
|
274
|
+
return default
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def account_record(client: Any) -> Any:
|
|
278
|
+
"""What to pass as a device client's second constructor argument.
|
|
279
|
+
|
|
280
|
+
Shape B wants the ``UserInformation`` dataclass; shape A wants the raw
|
|
281
|
+
``login_info`` dict. See ``device_client_args``.
|
|
282
|
+
"""
|
|
283
|
+
if HAS_TYPED_ACCOUNT:
|
|
284
|
+
return client.user_info
|
|
285
|
+
return client.login_info
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def device_client_args(
|
|
289
|
+
device_record: Any,
|
|
290
|
+
raw_device: dict,
|
|
291
|
+
typed_account: Any,
|
|
292
|
+
login_info: Optional[dict],
|
|
293
|
+
) -> tuple:
|
|
294
|
+
"""The ``(device, user_info)`` pair upstream's ``DeviceClient`` expects.
|
|
295
|
+
|
|
296
|
+
Shape B: ``(DeviceModel, UserInformation)`` - typed dataclasses.
|
|
297
|
+
Shape A: ``(dict, dict)`` - the raw cloud records, natively.
|
|
298
|
+
|
|
299
|
+
Shape A taking raw dicts is why this package carried ``raw_device`` /
|
|
300
|
+
``login_info`` alongside the typed models in the first place: the typed
|
|
301
|
+
round trip drops every camera-only field. On shape A the originals go
|
|
302
|
+
straight through and nothing is lost.
|
|
303
|
+
|
|
304
|
+
Takes the four values rather than the account client, because the only
|
|
305
|
+
caller (``CameraDeviceClient.__init__``) is handed them directly and has no
|
|
306
|
+
client reference. ``typed_account`` may be a ``UserInformation`` or, from
|
|
307
|
+
an older caller, already a dict - on shape A a dict ``login_info`` wins and
|
|
308
|
+
a dict ``typed_account`` is the fallback, so both call styles work.
|
|
309
|
+
"""
|
|
310
|
+
if HAS_TYPED_ACCOUNT:
|
|
311
|
+
return (device_record, typed_account)
|
|
312
|
+
account = login_info if isinstance(login_info, dict) else None
|
|
313
|
+
if account is None:
|
|
314
|
+
account = typed_account if isinstance(typed_account, dict) else {}
|
|
315
|
+
if not isinstance(typed_account, dict) and hasattr(typed_account, "to_dict"):
|
|
316
|
+
account = typed_account.to_dict()
|
|
317
|
+
return (raw_device, account)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
#: True when upstream's ``DeviceClient`` exposes a ``read_data`` seam - one
|
|
321
|
+
#: decrypted frame at a time, which this package hooks to recover camera-only
|
|
322
|
+
#: attribute keys that upstream's typed ``DeviceAttr`` model drops. Shape A
|
|
323
|
+
#: inlines the read into ``receive_data``, so there is no seam and the raw JSON
|
|
324
|
+
#: never escapes. See ``CameraDeviceClient._notify_status_update``.
|
|
325
|
+
try:
|
|
326
|
+
from aidot.device_client import DeviceClient as _DeviceClient
|
|
327
|
+
HAS_READ_DATA_SEAM = callable(getattr(_DeviceClient, "read_data", None))
|
|
328
|
+
except ImportError: # pragma: no cover - upstream is a hard dependency
|
|
329
|
+
HAS_READ_DATA_SEAM = False
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# --------------------------------------------------------------------------- #
|
|
333
|
+
# Discovery: static ``Discover`` (shape B) -> instance ``Discover`` (shape A)
|
|
334
|
+
# --------------------------------------------------------------------------- #
|
|
335
|
+
|
|
336
|
+
#: True when upstream's ``Discover`` keeps discovered addresses in the
|
|
337
|
+
#: process-wide ``DISCOVERED_DEVICE`` class dict. Shape A made discovery
|
|
338
|
+
#: instance-scoped (``self._discover.discovered_device``), which is the shape
|
|
339
|
+
#: this package's ``CameraDiscover`` already presents - so on shape A the map
|
|
340
|
+
#: no longer has to be shared through the base class to reach upstream's
|
|
341
|
+
#: ``get_device_client``.
|
|
342
|
+
try:
|
|
343
|
+
from aidot.discover import Discover as _Discover
|
|
344
|
+
HAS_SHARED_DISCOVERY_MAP = isinstance(
|
|
345
|
+
getattr(_Discover, "DISCOVERED_DEVICE", None), dict
|
|
346
|
+
)
|
|
347
|
+
except ImportError: # pragma: no cover - upstream is a hard dependency
|
|
348
|
+
HAS_SHARED_DISCOVERY_MAP = False
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def broadcast_protocol_args(callback: Any, user_id: str) -> tuple:
|
|
352
|
+
"""Positional args for upstream's ``BroadcastProtocol.__init__``.
|
|
353
|
+
|
|
354
|
+
Shape B takes ``(callback)`` and has no notion of the account id; shape A
|
|
355
|
+
takes ``(callback, user_id)`` and uses it to build the request payload.
|
|
356
|
+
"""
|
|
357
|
+
import inspect
|
|
358
|
+
from aidot.discover import BroadcastProtocol
|
|
359
|
+
|
|
360
|
+
params = list(inspect.signature(BroadcastProtocol.__init__).parameters)
|
|
361
|
+
return (callback, user_id) if "user_id" in params else (callback,)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
#: Names this module intends as its public surface within the package.
|
|
365
|
+
__all__ = [
|
|
366
|
+
"API_URL_TEMPLATE",
|
|
367
|
+
"APP_ID",
|
|
368
|
+
"DEFAULT_REGION",
|
|
369
|
+
"DEVICE_STATE_IS_UPSTREAMS",
|
|
370
|
+
"HAS_READ_DATA_SEAM",
|
|
371
|
+
"HAS_SHARED_DISCOVERY_MAP",
|
|
372
|
+
"HAS_TYPED_ACCOUNT",
|
|
373
|
+
"PUBLIC_KEY_PEM",
|
|
374
|
+
"RECONNECT_HANDLE_ATTRS",
|
|
375
|
+
"UPSTREAM_SHAPE",
|
|
376
|
+
"DeviceModel",
|
|
377
|
+
"DeviceRecord",
|
|
378
|
+
"DeviceState",
|
|
379
|
+
"UserInformation",
|
|
380
|
+
"account_record",
|
|
381
|
+
"account_refresh_token",
|
|
382
|
+
"account_region",
|
|
383
|
+
"account_token_ttl",
|
|
384
|
+
"aes_decrypt",
|
|
385
|
+
"aes_decrypt_to_json",
|
|
386
|
+
"aes_encrypt",
|
|
387
|
+
"api_get_devices",
|
|
388
|
+
"api_get_houses",
|
|
389
|
+
"api_get_products",
|
|
390
|
+
"api_refresh_token",
|
|
391
|
+
"broadcast_protocol_args",
|
|
392
|
+
"cancel_pending_reconnect",
|
|
393
|
+
"device_client_args",
|
|
394
|
+
"device_session_authenticated",
|
|
395
|
+
"rsa_encrypt",
|
|
396
|
+
]
|
|
@@ -854,6 +854,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
854
854
|
self._last_frame_time: float = 0.0
|
|
855
855
|
self._keepalive_rtsp_url: Optional[str] = None # local serve URL (go2rtc pulls)
|
|
856
856
|
self._go2rtc_url: Optional[str] = None # go2rtc API base (prefer-go2rtc)
|
|
857
|
+
# Whether WE own this camera's go2rtc stream registration. False when the
|
|
858
|
+
# consumer registers the stream itself and passes the URL for viewer
|
|
859
|
+
# queries only - in which case we must neither add nor REMOVE it.
|
|
860
|
+
self._go2rtc_manages_stream: bool = True
|
|
857
861
|
self._go2rtc_pull_url: Optional[str] = None # go2rtc pull URL once registered
|
|
858
862
|
self._go2rtc_task: "Optional[asyncio.Task[None]]" = None
|
|
859
863
|
# Set by the DTLS serve loop once ffmpeg is bound + serving, so
|
|
@@ -2789,6 +2793,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2789
2793
|
fast_connect: Optional[bool] = None,
|
|
2790
2794
|
sdes_audio: Optional[bool] = None,
|
|
2791
2795
|
go2rtc_url: Optional[str] = None,
|
|
2796
|
+
go2rtc_register: bool = True,
|
|
2792
2797
|
live_stream_param: Optional[bool] = None,
|
|
2793
2798
|
serve_relay: Optional[bool] = None,
|
|
2794
2799
|
stream_idle_s: Optional[float] = None,
|
|
@@ -2828,6 +2833,22 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2828
2833
|
``AIDOT_SERVE_RELAY`` env var (default on); set False to serve ffmpeg
|
|
2829
2834
|
directly on the public port (the pre-0.7.18 behaviour).
|
|
2830
2835
|
|
|
2836
|
+
``go2rtc_url`` points at a go2rtc API base. It does two things: register
|
|
2837
|
+
the local serve with go2rtc (exposing its low-latency pull URL), and let
|
|
2838
|
+
the idle-release logic ask go2rtc **who is watching**.
|
|
2839
|
+
|
|
2840
|
+
``go2rtc_register=False`` keeps the second and drops the first, for a
|
|
2841
|
+
consumer that registers the stream itself - registering it twice
|
|
2842
|
+
re-points the source mid-stream and was observed leaving DTLS cameras
|
|
2843
|
+
producing nothing at all. Such a consumer must still pass ``go2rtc_url``:
|
|
2844
|
+
withholding it does not merely lose a nicety, it silently disables viewer
|
|
2845
|
+
detection, and in SDES **push** mode that is the only viewer signal there
|
|
2846
|
+
is (the serve port is go2rtc's shared RTSP port, where every camera's own
|
|
2847
|
+
publisher is connected, so a socket check would report a viewer for every
|
|
2848
|
+
camera forever). With no signal ``_viewer_present`` answers "unknown",
|
|
2849
|
+
``_idle_release_due`` correctly never releases on unknown, and a battery
|
|
2850
|
+
camera's keepalive renews forever against a camera nobody is watching.
|
|
2851
|
+
|
|
2831
2852
|
``stream_idle_s`` sets the no-viewer idle-release window in seconds,
|
|
2832
2853
|
overriding the ``AIDOT_STREAM_IDLE_S`` env default (120). ``0`` (or
|
|
2833
2854
|
negative) keeps the warm WebRTC session forever so re-views are instant
|
|
@@ -2860,6 +2881,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2860
2881
|
return
|
|
2861
2882
|
self._keepalive_rtsp_url = rtsp_push_url
|
|
2862
2883
|
self._go2rtc_url = go2rtc_url
|
|
2884
|
+
self._go2rtc_manages_stream = bool(go2rtc_register)
|
|
2863
2885
|
self._streaming_active = True
|
|
2864
2886
|
# App parity: keep a battery camera awake for the whole view (mains cams
|
|
2865
2887
|
# never sleep, so this is a no-op there). Single-instance; cancelled in
|
|
@@ -2874,7 +2896,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2874
2896
|
# Prefer-go2rtc: if a go2rtc server is configured, register the local
|
|
2875
2897
|
# serve with it once ready and expose its pull URL (ffmpeg fallback
|
|
2876
2898
|
# otherwise). Opt-in: with no go2rtc_url, behaviour is unchanged.
|
|
2877
|
-
|
|
2899
|
+
#
|
|
2900
|
+
# ``go2rtc_register=False`` stores the URL for QUERIES ONLY and skips
|
|
2901
|
+
# registration. That exists for a consumer that registers the stream
|
|
2902
|
+
# itself: registering it twice re-points the source while a stream is
|
|
2903
|
+
# mid-flight, which was observed leaving DTLS cameras producing nothing
|
|
2904
|
+
# at all. Such a consumer previously had to withhold go2rtc_url
|
|
2905
|
+
# entirely, which silently disabled viewer detection - and in SDES push
|
|
2906
|
+
# mode that means a battery camera can never idle-release, because
|
|
2907
|
+
# ``_viewer_present`` answers "unknown" and ``_idle_release_due``
|
|
2908
|
+
# (correctly) never releases on unknown. The keepalive then renews
|
|
2909
|
+
# forever against a camera nobody is watching.
|
|
2910
|
+
if go2rtc_url and go2rtc_register:
|
|
2878
2911
|
self._go2rtc_task = asyncio.ensure_future(self._register_with_go2rtc())
|
|
2879
2912
|
|
|
2880
2913
|
@property
|
|
@@ -2934,9 +2967,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2934
2967
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_register_with_go2rtc', exc_info=True)
|
|
2935
2968
|
|
|
2936
2969
|
async def _deregister_go2rtc(self) -> None:
|
|
2937
|
-
"""Remove this camera's stream from go2rtc (best-effort).
|
|
2970
|
+
"""Remove this camera's stream from go2rtc (best-effort).
|
|
2971
|
+
|
|
2972
|
+
Only ever removes a registration this client created. With
|
|
2973
|
+
``go2rtc_register=False`` the consumer owns the stream and merely lent us
|
|
2974
|
+
the URL to ask who is watching; deleting its stream on stop or
|
|
2975
|
+
idle-release would tear down the very thing it is serving from.
|
|
2976
|
+
"""
|
|
2938
2977
|
base = self._go2rtc_url
|
|
2939
2978
|
self._go2rtc_pull_url = None
|
|
2979
|
+
if not self._go2rtc_manages_stream:
|
|
2980
|
+
return
|
|
2940
2981
|
# Deliberately NOT gated on `pull`: go2rtc answers the register call with
|
|
2941
2982
|
# 400 when it cannot immediately validate the source (a camera that has
|
|
2942
2983
|
# not started producing yet), yet it still keeps the stream registered.
|
|
@@ -3099,22 +3140,21 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3099
3140
|
|
|
3100
3141
|
name = self._go2rtc_stream_name()
|
|
3101
3142
|
base = self._go2rtc_url
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
return None
|
|
3143
|
+
|
|
3144
|
+
# In PUSH mode the "serve port" is go2rtc's SHARED RTSP port (8554),
|
|
3145
|
+
# where every camera's own publisher is connected - so the socket
|
|
3146
|
+
# fallback below is not merely unavailable, it is WRONG: it would report
|
|
3147
|
+
# a viewer for every camera forever and nothing would ever release.
|
|
3148
|
+
# Tracked separately from `base` because it must gate the fallback even
|
|
3149
|
+
# when go2rtc IS configured but the query fails.
|
|
3150
|
+
push_mode = bool(self._keepalive_rtsp_url) and not str(
|
|
3151
|
+
self._keepalive_rtsp_url).startswith("http")
|
|
3152
|
+
|
|
3153
|
+
# Ask go2rtc when we can. This is the only viewer signal that works in
|
|
3154
|
+
# push mode, which is why a consumer that registers its own stream
|
|
3155
|
+
# should still pass go2rtc_url with go2rtc_register=False rather than
|
|
3156
|
+
# withholding it (withholding it silently pins push-mode cameras to
|
|
3157
|
+
# "unknown", and unknown never releases).
|
|
3118
3158
|
if base and name:
|
|
3119
3159
|
try:
|
|
3120
3160
|
import aiohttp
|
|
@@ -3128,6 +3168,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3128
3168
|
return _answer
|
|
3129
3169
|
except Exception as exc:
|
|
3130
3170
|
_LOGGER.debug("go2rtc viewer check failed: %s", exc)
|
|
3171
|
+
|
|
3172
|
+
if push_mode:
|
|
3173
|
+
# Answer "unknown" instead of a confident lie - the caller then holds
|
|
3174
|
+
# the stream rather than tearing down a live view.
|
|
3175
|
+
_LOGGER.debug(
|
|
3176
|
+
"cannot tell whether anyone is watching: the serve is an RTSP "
|
|
3177
|
+
"push to a shared port and go2rtc %s",
|
|
3178
|
+
"did not answer" if base else "was not configured",
|
|
3179
|
+
)
|
|
3180
|
+
self._viewer_cache = (_now_mono, None)
|
|
3181
|
+
return None
|
|
3182
|
+
|
|
3131
3183
|
_answer = self._sdes_serve_consumer_present(port)
|
|
3132
3184
|
self._viewer_cache = (_now_mono, _answer)
|
|
3133
3185
|
return _answer
|
{python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/sdes_open.py
RENAMED
|
@@ -181,6 +181,46 @@ def _bridge_should_break(rc, teardown_requested: bool) -> bool:
|
|
|
181
181
|
return rc is not None and not teardown_requested
|
|
182
182
|
|
|
183
183
|
|
|
184
|
+
# Cap on peer-reflexive candidates learned from inbound probes. A camera has
|
|
185
|
+
# one working path; a handful of source addresses is normal churn (NAT rebind,
|
|
186
|
+
# audio vs video socket), anything beyond that is noise and must not be allowed
|
|
187
|
+
# to grow the nomination set without bound.
|
|
188
|
+
_MAX_PRFLX_CANDS = 4
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _record_peer_reflexive(known, discovered, observed, is_self=None):
|
|
192
|
+
"""Learn the address a STUN probe actually arrived from.
|
|
193
|
+
|
|
194
|
+
ICE calls this a peer-reflexive candidate (RFC 8445 s7.3.1.3): the camera
|
|
195
|
+
is reachable at the source of its own connectivity check whether or not it
|
|
196
|
+
ever advertised that address. We were nominating only what the answer SDP
|
|
197
|
+
listed, which fails whenever the listed address is not reachable from here
|
|
198
|
+
- a camera on a foreign subnet advertises a private candidate we have no
|
|
199
|
+
route to, the nomination goes into a black hole, and the session sits in
|
|
200
|
+
ICE "Checking" while the camera's probes keep arriving from an address we
|
|
201
|
+
never nominate. Observed on the A001064 PTZ while it was on a separate
|
|
202
|
+
192.168.100.0/24: it advertised 192.168.100.13 as its only candidate.
|
|
203
|
+
|
|
204
|
+
Returns the new discovered list, or ``discovered`` unchanged when there is
|
|
205
|
+
nothing to learn. Callers must REBIND rather than mutate in place - the
|
|
206
|
+
bridge thread iterates this list and must never see it change under it.
|
|
207
|
+
|
|
208
|
+
Pure function so the policy is unit-testable without a camera.
|
|
209
|
+
"""
|
|
210
|
+
if not observed:
|
|
211
|
+
return discovered
|
|
212
|
+
_ip, _port = observed
|
|
213
|
+
if not _ip or not _port:
|
|
214
|
+
return discovered
|
|
215
|
+
if is_self is not None and is_self(_ip):
|
|
216
|
+
return discovered
|
|
217
|
+
if observed in known or observed in discovered:
|
|
218
|
+
return discovered
|
|
219
|
+
if len(discovered) >= _MAX_PRFLX_CANDS:
|
|
220
|
+
return discovered
|
|
221
|
+
return [*discovered, observed]
|
|
222
|
+
|
|
223
|
+
|
|
184
224
|
class _SdesOpenMixin:
|
|
185
225
|
async def _open_sdes_stream(self, **kwargs) -> "SdesSession":
|
|
186
226
|
"""Allocate-and-hand-off wrapper around _open_sdes_stream_impl.
|
|
@@ -2940,6 +2980,31 @@ class _SdesOpenMixin:
|
|
|
2940
2980
|
f" {_bsrc[0]}:{_bsrc[1]}"
|
|
2941
2981
|
f" attrs=[{', '.join(_attrs)}]"
|
|
2942
2982
|
)
|
|
2983
|
+
# Learn where this probe actually came from. The
|
|
2984
|
+
# camera reached us from here, so this address is
|
|
2985
|
+
# known-good in a way an advertised one is not.
|
|
2986
|
+
# Relay-carried probes carry the camera's real
|
|
2987
|
+
# address in XOR-PEER-ADDRESS; _bsrc is the TURN
|
|
2988
|
+
# server and would be useless to nominate.
|
|
2989
|
+
_br_obs = (
|
|
2990
|
+
_br_cam_peer if _br_cam_peer
|
|
2991
|
+
else (None if _bsrc[0] == _hp_host else _bsrc)
|
|
2992
|
+
)
|
|
2993
|
+
_br_prflx_was = _bridge_uc_info["prflx"]
|
|
2994
|
+
_br_prflx_now = _record_peer_reflexive(
|
|
2995
|
+
_bridge_uc_info["cands"], _br_prflx_was,
|
|
2996
|
+
_br_obs, _is_self_peer_ip,
|
|
2997
|
+
)
|
|
2998
|
+
if _br_prflx_now is not _br_prflx_was:
|
|
2999
|
+
# Rebind, never append: the nomination tick
|
|
3000
|
+
# iterates this list.
|
|
3001
|
+
_bridge_uc_info["prflx"] = _br_prflx_now
|
|
3002
|
+
_status(
|
|
3003
|
+
f"ICE: learned peer-reflexive camera"
|
|
3004
|
+
f" candidate {_br_obs[0]}:{_br_obs[1]}"
|
|
3005
|
+
f" (not in the {len(_bridge_uc_info['cands'])}"
|
|
3006
|
+
f" advertised candidate(s)) - will nominate it"
|
|
3007
|
+
)
|
|
2943
3008
|
try:
|
|
2944
3009
|
if _br_turn_peer_ip is None and _bsrc[0] != _hp_host:
|
|
2945
3010
|
_br_prefer_direct_stun[_bs] = True
|
|
@@ -3814,6 +3879,16 @@ class _SdesOpenMixin:
|
|
|
3814
3879
|
# Fall back to the creds parsed late into _bridge_uc_info so this
|
|
3815
3880
|
# ungated periodic tick still nominates them. [SDES-LATECREDS-FIX]
|
|
3816
3881
|
_uc_cands = _cam_ice_cands or _bridge_uc_info.get("cands")
|
|
3882
|
+
# Union in anything learned from the camera's own probes.
|
|
3883
|
+
# Advertised candidates stay FIRST and are still nominated,
|
|
3884
|
+
# so a camera we can already reach directly is unaffected;
|
|
3885
|
+
# this only adds a path where there was none.
|
|
3886
|
+
_uc_prflx = _bridge_uc_info.get("prflx")
|
|
3887
|
+
if _uc_prflx:
|
|
3888
|
+
_uc_cands = [
|
|
3889
|
+
*(_uc_cands or []),
|
|
3890
|
+
*(c for c in _uc_prflx if c not in (_uc_cands or [])),
|
|
3891
|
+
]
|
|
3817
3892
|
_uc_cufrag = _cam_ice_ufrag or _bridge_uc_info.get("ufrag")
|
|
3818
3893
|
_uc_cpwd = _cam_ice_pwd or _bridge_uc_info.get("pwd")
|
|
3819
3894
|
if _uc_cands and _uc_cufrag and _uc_cpwd and (_br_now - _br_last_uc) >= 2.5:
|
|
@@ -3884,6 +3959,10 @@ class _SdesOpenMixin:
|
|
|
3884
3959
|
"pwd": _cam_ice_pwd,
|
|
3885
3960
|
"cands": list(_cam_ice_cands),
|
|
3886
3961
|
"sent": bool(_cam_ice_cands), # True if already sent at setup
|
|
3962
|
+
# Addresses the camera's own probes arrived from, which may differ
|
|
3963
|
+
# from anything it advertised. Written by the bridge thread only;
|
|
3964
|
+
# read there too, so no cross-thread ordering to reason about.
|
|
3965
|
+
"prflx": [],
|
|
3887
3966
|
}
|
|
3888
3967
|
|
|
3889
3968
|
_bridge_thread = _threading_br.Thread(
|
{python_aidot_cameras-0.12.17 → python_aidot_cameras-0.13.1}/aidot_cameras/camera/webrtc_open.py
RENAMED
|
@@ -335,7 +335,7 @@ class _WebRTCOpenMixin:
|
|
|
335
335
|
# only the higher of the camera's consecutive [P, P+1] ICE ports (its
|
|
336
336
|
# live DTLS socket). aioice's aggressive nomination of BOTH ports made
|
|
337
337
|
# the camera latch the lower one and withhold DTLS - the dominant ~25%
|
|
338
|
-
# failure. A/B on
|
|
338
|
+
# failure. A/B on an A000088: high-port-only ~87% vs ~25% baseline vs
|
|
339
339
|
# ~12% low-port. Self-gating no-op for non-A000088 peers.
|
|
340
340
|
# See _install_highport_nomination_patch.
|
|
341
341
|
_install_highport_nomination_patch()
|