python-aidot-cameras 0.14.0__tar.gz → 0.15.0b2__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.14.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.15.0b2}/PKG-INFO +1 -1
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/client.py +16 -5
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/constants.py +52 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/protocol.py +48 -1
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/sdes.py +31 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/sdes_open.py +160 -15
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/webrtc_open.py +35 -9
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/client.py +6 -2
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/pyproject.toml +16 -1
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/python_aidot_cameras.egg-info/SOURCES.txt +8 -0
- python_aidot_cameras-0.15.0b2/tests/test_audio_mux_offline.py +147 -0
- python_aidot_cameras-0.15.0b2/tests/test_env_seams.py +156 -0
- python_aidot_cameras-0.15.0b2/tests/test_media_wait_floor.py +38 -0
- python_aidot_cameras-0.15.0b2/tests/test_restart_cycle.py +103 -0
- python_aidot_cameras-0.15.0b2/tests/test_scripts_importable.py +50 -0
- python_aidot_cameras-0.15.0b2/tests/test_sdes_terminal_ack_latency.py +138 -0
- python_aidot_cameras-0.15.0b2/tests/test_sdp_narrowing_properties.py +133 -0
- python_aidot_cameras-0.15.0b2/tests/test_sprop_staleness.py +83 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/LICENSE +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/README.md +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_upstream.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/ble_gateway.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/device_client.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/setup.cfg +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_answer_sdp_video_pt.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_battery_detection.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_battery_evidence_corroboration.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_battery_skip_turn.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_ble_gateway.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_constants_imports_resolve.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dormant_camera_cleanup.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_mux_terminal_write.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_ice_peer_reflexive.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_live_validate_policy.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_peerid_reuse.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_terminal_ack.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_sdp_payload_type_narrowing.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_shared_house_seam.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_turn_create_permission.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_viewer_present_push_mode.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_wait_or_event.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/tests/test_wake_readiness_retry.py +0 -0
{python_aidot_cameras-0.14.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.15.0b2}/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.15.0b2
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
{python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/client.py
RENAMED
|
@@ -22,9 +22,11 @@ from ..const import (
|
|
|
22
22
|
from .constants import (
|
|
23
23
|
_LEEDARSON_APP_ID,
|
|
24
24
|
_LIVE_PLAY_NOT_READY,
|
|
25
|
-
_SMARTHOME_URL_TEMPLATE,
|
|
25
|
+
_SMARTHOME_URL_TEMPLATE, # noqa: F401 - re-exported for back-compat; reads go through smarthome_base()
|
|
26
26
|
GETSTREAMCTRL_CMD, # noqa: F401 - re-exported for back-compat (public pair; unused in-module)
|
|
27
27
|
SDES_SPEAKERSTART_DELAY, # noqa: F401 - used by the sdes_open mixin; kept re-exported
|
|
28
|
+
aidot_api_base,
|
|
29
|
+
smarthome_base,
|
|
28
30
|
)
|
|
29
31
|
from .constants import ( # noqa: F401 - re-exported for device_client / back-compat
|
|
30
32
|
TALK_PCM_FRAME_BYTES,
|
|
@@ -1156,7 +1158,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1156
1158
|
|
|
1157
1159
|
@property
|
|
1158
1160
|
def _smarthome_base(self) -> str:
|
|
1159
|
-
return
|
|
1161
|
+
return smarthome_base(self._region)
|
|
1160
1162
|
|
|
1161
1163
|
def set_token_refresh_cb(self, cb: "Callable") -> None:
|
|
1162
1164
|
"""Register the AidotClient coroutine that refreshes the access token."""
|
|
@@ -1371,6 +1373,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1371
1373
|
if self._mqtt_url:
|
|
1372
1374
|
return self._mqtt_url
|
|
1373
1375
|
|
|
1376
|
+
# Test seam: point the whole client at a local broker without any cloud
|
|
1377
|
+
# call (paho derives ws-vs-wss and the port from the scheme, so a plain
|
|
1378
|
+
# "ws://127.0.0.1:PORT/mqtt" needs nothing else). Unset in production.
|
|
1379
|
+
_env_mqtt = os.environ.get("AIDOT_MQTT_URL")
|
|
1380
|
+
if _env_mqtt:
|
|
1381
|
+
_LOGGER.debug("AIDOT_MQTT_URL override in effect: %s", _env_mqtt)
|
|
1382
|
+
self._mqtt_url = _env_mqtt
|
|
1383
|
+
return self._mqtt_url
|
|
1384
|
+
|
|
1374
1385
|
import aiohttp
|
|
1375
1386
|
|
|
1376
1387
|
async def _fetch():
|
|
@@ -1603,11 +1614,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1603
1614
|
|
|
1604
1615
|
@property
|
|
1605
1616
|
def _aidot_v21_base(self) -> str:
|
|
1606
|
-
return f"
|
|
1617
|
+
return f"{aidot_api_base(self._region)}/v21"
|
|
1607
1618
|
|
|
1608
1619
|
@property
|
|
1609
1620
|
def _aidot_v32_base(self) -> str:
|
|
1610
|
-
return f"
|
|
1621
|
+
return f"{aidot_api_base(self._region)}/v32/api/ipc"
|
|
1611
1622
|
|
|
1612
1623
|
def _aidot_headers(self) -> dict:
|
|
1613
1624
|
# Auth headers for the AiDot platform API (prod-{region}-api.arnoo.com).
|
|
@@ -4847,7 +4858,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
4847
4858
|
return None
|
|
4848
4859
|
|
|
4849
4860
|
url = (
|
|
4850
|
-
f"
|
|
4861
|
+
f"{aidot_api_base(self._region)}"
|
|
4851
4862
|
f"/v29/api/webrtc/iceConfig?forceRefresh=0"
|
|
4852
4863
|
)
|
|
4853
4864
|
|
{python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/constants.py
RENAMED
|
@@ -4,6 +4,7 @@ Extracted verbatim from client.py (behavior-preserving). client.py
|
|
|
4
4
|
re-imports these names, so the public/runtime surface is unchanged.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import os
|
|
7
8
|
import struct
|
|
8
9
|
|
|
9
10
|
# AppKey from LDSAppOpenSDK CocoaPods docs (kLDSAppOpenSDKKey = "appa070")
|
|
@@ -17,6 +18,57 @@ _LEEDARSON_APP_ID = "1392315867093508098"
|
|
|
17
18
|
# e.g. "us" -> "https://us-smarthome.arnoo.com:443"
|
|
18
19
|
_SMARTHOME_URL_TEMPLATE = "https://{region}-smarthome.arnoo.com:443"
|
|
19
20
|
|
|
21
|
+
# AiDot platform API base (the prod-{region}-api.arnoo.com family).
|
|
22
|
+
_AIDOT_API_BASE_TEMPLATE = "https://prod-{region}-api.arnoo.com"
|
|
23
|
+
|
|
24
|
+
# --------------------------------------------------------------------------- #
|
|
25
|
+
# Test-environment seams.
|
|
26
|
+
#
|
|
27
|
+
# Every cloud endpoint the camera layer contacts routes through one of these
|
|
28
|
+
# helpers, so an end-to-end test can point the WHOLE client at a local fake
|
|
29
|
+
# environment via env vars (the same opt-in pattern as
|
|
30
|
+
# AIDOT_SDES_HOLEPUNCH_HOST). Read at call time, never cached at import, and
|
|
31
|
+
# defaulting to today's production URLs - unset means byte-identical behavior.
|
|
32
|
+
#
|
|
33
|
+
# These exist so the e2e tier CANNOT reach the real cloud: without them the
|
|
34
|
+
# fake-lab tests fall through to prod-{region}-api.arnoo.com and the live MQTT
|
|
35
|
+
# broker, which is real egress with test credentials from CI.
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def smarthome_base(region: str) -> str:
|
|
39
|
+
"""Smarthome API base for ``region`` (AIDOT_SMARTHOME_URL_TEMPLATE override)."""
|
|
40
|
+
tmpl = os.environ.get("AIDOT_SMARTHOME_URL_TEMPLATE") or _SMARTHOME_URL_TEMPLATE
|
|
41
|
+
return tmpl.format(region=region)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def aidot_api_base(region: str) -> str:
|
|
45
|
+
"""Platform API base for ``region`` (AIDOT_API_BASE_TEMPLATE override)."""
|
|
46
|
+
tmpl = os.environ.get("AIDOT_API_BASE_TEMPLATE") or _AIDOT_API_BASE_TEMPLATE
|
|
47
|
+
return tmpl.format(region=region)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ice_uris_from_env(var: str, default: list) -> list:
|
|
51
|
+
"""Comma-separated ICE URI list from ``var``; unset -> default, "" -> []."""
|
|
52
|
+
env = os.environ.get(var)
|
|
53
|
+
if env is None:
|
|
54
|
+
return list(default)
|
|
55
|
+
return [u.strip() for u in env.split(",") if u.strip()]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def stun_server_uris() -> list:
|
|
59
|
+
"""Default STUN server URIs (AIDOT_STUN_SERVERS override; "" disables)."""
|
|
60
|
+
return _ice_uris_from_env("AIDOT_STUN_SERVERS", ["stun:stun.l.google.com:19302"])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fallback_turn_uris() -> list:
|
|
64
|
+
"""TURN relay URIs appended when the cloud ICE config carries none
|
|
65
|
+
(AIDOT_TURN_SERVERS override; "" disables the hardcoded Arnoo fallback)."""
|
|
66
|
+
return _ice_uris_from_env(
|
|
67
|
+
"AIDOT_TURN_SERVERS",
|
|
68
|
+
["stun:3.230.182.123:3478", "turn:3.230.182.123:5349"],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
20
72
|
# --------------------------------------------------------------------------- #
|
|
21
73
|
# Playback TCP binary framing constants
|
|
22
74
|
#
|
{python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/protocol.py
RENAMED
|
@@ -583,6 +583,26 @@ def _sprop_cache_path(devid: str) -> str:
|
|
|
583
583
|
return os.path.join(_SPROP_DIR, f"{safe}.sprop")
|
|
584
584
|
|
|
585
585
|
|
|
586
|
+
def _sprop_unstable_path(devid: str) -> str:
|
|
587
|
+
"""Marker file: this camera does NOT keep its parameter sets stable."""
|
|
588
|
+
return _sprop_cache_path(devid) + ".unstable"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _sprop_is_unstable(devid: str) -> bool:
|
|
592
|
+
"""True once this camera has been seen to change its parameter sets.
|
|
593
|
+
|
|
594
|
+
Injecting a cached ``sprop-parameter-sets`` is only safe while the camera
|
|
595
|
+
keeps sending the SAME sets. An A001064 was measured changing its
|
|
596
|
+
``seq_parameter_set_id`` between sessions (cache held id 0, the stream then
|
|
597
|
+
referenced id 3), and out-of-band parameter sets that name the wrong id are
|
|
598
|
+
far worse than none: ffmpeg initialises its decoder from them, every slice
|
|
599
|
+
then references an id the decoder does not have, and the whole session
|
|
600
|
+
decodes nothing - "sps_id 3 out of range", "non-existing PPS 0 referenced",
|
|
601
|
+
"no frame!". A MISSING set only costs the wait for the next in-band IDR.
|
|
602
|
+
"""
|
|
603
|
+
return os.path.exists(_sprop_unstable_path(devid))
|
|
604
|
+
|
|
605
|
+
|
|
586
606
|
def _load_sprop(devid: str) -> "Optional[str]":
|
|
587
607
|
"""Cached ``sprop-parameter-sets`` for ``devid`` (captured from a prior
|
|
588
608
|
stream), or None. Fail-safe: any error -> None (SDP omits sprop = today's
|
|
@@ -596,8 +616,33 @@ def _load_sprop(devid: str) -> "Optional[str]":
|
|
|
596
616
|
|
|
597
617
|
|
|
598
618
|
def _save_sprop(devid: str, sprop: str) -> None:
|
|
599
|
-
"""Persist the ``sprop-parameter-sets`` string for ``devid`` (best-effort).
|
|
619
|
+
"""Persist the ``sprop-parameter-sets`` string for ``devid`` (best-effort).
|
|
620
|
+
|
|
621
|
+
If a DIFFERENT value was already cached, this camera does not keep its
|
|
622
|
+
parameter sets stable: mark it and drop the cache, so nothing is injected
|
|
623
|
+
for it again (see _sprop_is_unstable). The caller only reaches here from
|
|
624
|
+
in-band capture, so such a camera demonstrably sends its sets in-band and
|
|
625
|
+
loses nothing by the injection being skipped.
|
|
626
|
+
"""
|
|
627
|
+
if _sprop_is_unstable(devid):
|
|
628
|
+
return # already known unstable; nothing to cache for this camera
|
|
600
629
|
try:
|
|
630
|
+
prev = _load_sprop(devid)
|
|
631
|
+
if prev is not None and prev != sprop:
|
|
632
|
+
_LOGGER.warning(
|
|
633
|
+
"camera %s: in-band parameter sets differ from the cached ones - "
|
|
634
|
+
"this camera changes them between sessions, so out-of-band "
|
|
635
|
+
"injection is now disabled for it (a stale set decodes nothing)",
|
|
636
|
+
devid,
|
|
637
|
+
)
|
|
638
|
+
try:
|
|
639
|
+
os.makedirs(_SPROP_DIR, exist_ok=True)
|
|
640
|
+
with open(_sprop_unstable_path(devid), "w") as fh:
|
|
641
|
+
fh.write("parameter sets changed between sessions\n")
|
|
642
|
+
os.remove(_sprop_cache_path(devid))
|
|
643
|
+
except OSError:
|
|
644
|
+
pass
|
|
645
|
+
return
|
|
601
646
|
os.makedirs(_SPROP_DIR, exist_ok=True)
|
|
602
647
|
tmp = _sprop_cache_path(devid) + ".tmp"
|
|
603
648
|
with open(tmp, "w") as fh:
|
|
@@ -616,6 +661,8 @@ def _inject_sprop(sdp: str, devid: str) -> str:
|
|
|
616
661
|
in-band SPS loss). No-op if nothing is cached yet or sprop is already
|
|
617
662
|
present. Applied at every ffmpeg-input SDP write so both the serve path and
|
|
618
663
|
the snapshot/file path benefit."""
|
|
664
|
+
if _sprop_is_unstable(devid):
|
|
665
|
+
return sdp
|
|
619
666
|
sprop = _load_sprop(devid)
|
|
620
667
|
if not sprop or "sprop-parameter-sets=" in sdp:
|
|
621
668
|
return sdp
|
|
@@ -34,7 +34,10 @@ class SdesSession:
|
|
|
34
34
|
cmd_chan=None,
|
|
35
35
|
talk_state=None,
|
|
36
36
|
media_progress=None,
|
|
37
|
+
media_counts=None,
|
|
37
38
|
teardown_requested=None,
|
|
39
|
+
first_video_pt=None,
|
|
40
|
+
first_audio_pt=None,
|
|
38
41
|
) -> None:
|
|
39
42
|
self._proc = proc
|
|
40
43
|
self._sdp_path = sdp_path
|
|
@@ -48,6 +51,14 @@ class SdesSession:
|
|
|
48
51
|
# that stopped sending (battery teardown ~49-72s) and reconnect - ffmpeg
|
|
49
52
|
# itself never exits on a dead UDP input, so wait_done() alone would hang.
|
|
50
53
|
self._media_progress = media_progress if media_progress is not None else [0.0]
|
|
54
|
+
# Shared with the bridge thread: [packets, bytes] forwarded to ffmpeg.
|
|
55
|
+
# See media_stats() - the SDES path never calls on_frame, so this is the
|
|
56
|
+
# in-process signal that media is actually flowing.
|
|
57
|
+
self._media_counts = media_counts if media_counts is not None else [0, 0]
|
|
58
|
+
# Shared with the bridge thread: the payload types the camera actually
|
|
59
|
+
# sent (video 96/97/98, audio 0/8), or None before first media.
|
|
60
|
+
self._first_video_pt = first_video_pt if first_video_pt is not None else [None]
|
|
61
|
+
self._first_audio_pt = first_audio_pt if first_audio_pt is not None else [None]
|
|
51
62
|
# Mutable one-element list shared with the bridge thread: [0] flips True
|
|
52
63
|
# the moment ANY locally-initiated ffmpeg-kill path fires (this stop(),
|
|
53
64
|
# the cold-open _reap(), the key-restart proc replace, or the DTLS-
|
|
@@ -146,6 +157,26 @@ class SdesSession:
|
|
|
146
157
|
"""time.monotonic() of the last media packet forwarded, 0.0 if none yet."""
|
|
147
158
|
return self._media_progress[0]
|
|
148
159
|
|
|
160
|
+
def media_stats(self) -> dict:
|
|
161
|
+
"""Snapshot of media actually forwarded to ffmpeg by the bridge.
|
|
162
|
+
|
|
163
|
+
The SDES path decodes nothing in-process (ffmpeg owns the media), so
|
|
164
|
+
``on_frame`` is never invoked and callers cannot count frames the way
|
|
165
|
+
the DTLS path allows. These counters are the supported substitute:
|
|
166
|
+
non-zero ``packets``/``bytes`` means real media reached the consumer.
|
|
167
|
+
|
|
168
|
+
Returns ``{packets, bytes, last_media_monotonic, video_pt, audio_pt}``;
|
|
169
|
+
the payload types are ``None`` until the camera's first packet of that
|
|
170
|
+
kind arrives.
|
|
171
|
+
"""
|
|
172
|
+
return {
|
|
173
|
+
"packets": self._media_counts[0],
|
|
174
|
+
"bytes": self._media_counts[1],
|
|
175
|
+
"last_media_monotonic": self._media_progress[0],
|
|
176
|
+
"video_pt": self._first_video_pt[0],
|
|
177
|
+
"audio_pt": self._first_audio_pt[0],
|
|
178
|
+
}
|
|
179
|
+
|
|
149
180
|
def _log_ffmpeg_stderr(self, stderr_bytes: bytes) -> None:
|
|
150
181
|
"""Log ffmpeg's captured stderr at a level that matches what happened.
|
|
151
182
|
|
{python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/sdes_open.py
RENAMED
|
@@ -15,7 +15,12 @@ import random
|
|
|
15
15
|
import time
|
|
16
16
|
from typing import Callable, Optional # noqa: F401 - method annotations
|
|
17
17
|
|
|
18
|
-
from
|
|
18
|
+
from ..exceptions import AidotCameraBusy
|
|
19
|
+
from .constants import (
|
|
20
|
+
_LIVE_PLAY_NOT_READY,
|
|
21
|
+
SDES_SPEAKERSTART_DELAY,
|
|
22
|
+
stun_server_uris,
|
|
23
|
+
)
|
|
19
24
|
from .models import VideoFrame # noqa: F401 - forward-ref annotation
|
|
20
25
|
from .sdes import SdesSession
|
|
21
26
|
from .protocol import (
|
|
@@ -173,6 +178,52 @@ def narrow_sdp_payload_types(sdp_text: str, keep_video=None, keep_audio=None) ->
|
|
|
173
178
|
return "".join(kept)
|
|
174
179
|
|
|
175
180
|
|
|
181
|
+
async def _sdes_await_answer_or_terminal(
|
|
182
|
+
answer_fut, terminal_error_fut, timeout: float, _status=None
|
|
183
|
+
):
|
|
184
|
+
"""Await the camera's SDES answer, but give up at once on a terminal ack.
|
|
185
|
+
|
|
186
|
+
A terminal webrtcResp ack (-50002 max-streams / -50015 SD-cap) means the
|
|
187
|
+
camera REFUSED this stream; the whole point of classifying it as terminal
|
|
188
|
+
is that neither waiting nor retrying can help. The SDES path used to watch
|
|
189
|
+
only ``answer_fut``, so a refusal cost the full answer budget and then a
|
|
190
|
+
pointless DTLS-fallback offer on top.
|
|
191
|
+
|
|
192
|
+
Raises ``AidotCameraBusy`` the moment the refusal lands, ``TimeoutError``
|
|
193
|
+
if neither arrives in ``timeout`` (the caller's existing no-answer path).
|
|
194
|
+
"""
|
|
195
|
+
if terminal_error_fut is None:
|
|
196
|
+
return await asyncio.wait_for(answer_fut, timeout=timeout)
|
|
197
|
+
|
|
198
|
+
_waits = {answer_fut, terminal_error_fut}
|
|
199
|
+
_done, _pending = await asyncio.wait(
|
|
200
|
+
_waits, timeout=timeout, return_when=asyncio.FIRST_COMPLETED
|
|
201
|
+
)
|
|
202
|
+
if terminal_error_fut in _done:
|
|
203
|
+
code, desc = terminal_error_fut.result()
|
|
204
|
+
if _status is not None:
|
|
205
|
+
_status(f"camera refused: ack {code} {desc} - terminal, not retrying")
|
|
206
|
+
raise AidotCameraBusy(code, desc)
|
|
207
|
+
if answer_fut in _done:
|
|
208
|
+
return answer_fut.result()
|
|
209
|
+
# Timed out. ``answer_fut`` MUST be cancelled here, exactly as the
|
|
210
|
+
# ``asyncio.wait_for(answer_fut, ...)`` this replaced did. The MQTT handler
|
|
211
|
+
# routes a late webrtcResp to ``second_answer_fut`` only ``if
|
|
212
|
+
# answer_fut.done()`` (webrtc_open.py), and the late-ICE-credential recovery
|
|
213
|
+
# reads ``second_answer_fut``. Leaving it pending swallows the late answer,
|
|
214
|
+
# so USE-CANDIDATE is never sent, the camera stays in ICE "Checking" and the
|
|
215
|
+
# view shows a black frame with no media - the 0.12.16 failure mode.
|
|
216
|
+
#
|
|
217
|
+
# ``terminal_error_fut`` is deliberately NOT cancelled: it is checked again
|
|
218
|
+
# after the open returns (webrtc_open.py) to surface a refusal that landed
|
|
219
|
+
# during the fallback.
|
|
220
|
+
answer_fut.cancel()
|
|
221
|
+
# Builtin TimeoutError (asyncio.TimeoutError is an alias since 3.11); this
|
|
222
|
+
# is what the caller's `except TimeoutError` no-answer path already catches,
|
|
223
|
+
# which is what triggers the DTLS fallback.
|
|
224
|
+
raise TimeoutError()
|
|
225
|
+
|
|
226
|
+
|
|
176
227
|
def _sdes_echo_wait_timeout(skip_liveplay: bool) -> float:
|
|
177
228
|
"""Seconds to block on the camera's webrtcReq echo before proceeding.
|
|
178
229
|
|
|
@@ -328,6 +379,10 @@ class _SdesOpenMixin:
|
|
|
328
379
|
rtsp_push_url: Optional[str] = None,
|
|
329
380
|
talk: bool = False,
|
|
330
381
|
ice_cands_seen=None,
|
|
382
|
+
# So the SDES answer-wait can abandon at once when the camera says
|
|
383
|
+
# -50002/-50015 instead of waiting out its budget and then paying for a
|
|
384
|
+
# DTLS-fallback offer that cannot succeed.
|
|
385
|
+
terminal_error_fut=None,
|
|
331
386
|
) -> "SdesSession":
|
|
332
387
|
"""SDES-SRTP streaming path using a hand-crafted SDP offer and ffmpeg.
|
|
333
388
|
|
|
@@ -341,6 +396,27 @@ class _SdesOpenMixin:
|
|
|
341
396
|
import base64
|
|
342
397
|
import subprocess
|
|
343
398
|
|
|
399
|
+
# Stop the MQTT signaling thread on ANY abnormal exit from this impl.
|
|
400
|
+
# The explicit `outgoing_q.put_nowait(None)` calls further down cover the
|
|
401
|
+
# paths that existed when they were written, but an exception raised
|
|
402
|
+
# before one of them is reached (AidotCameraBusy on a terminal ack, or a
|
|
403
|
+
# CancelledError mid-handshake) skipped the sentinel entirely and left
|
|
404
|
+
# `_mqtt_session_sync` running in an executor thread for its full 3600s.
|
|
405
|
+
# Those orphans all reconnect with the same stable account clientId, so
|
|
406
|
+
# they take the session away from later opens and signaling for other
|
|
407
|
+
# cameras on the account stops being delivered until HA restarts.
|
|
408
|
+
# The wrapper only closes this stack when the impl did NOT return a
|
|
409
|
+
# session, so a live session is unaffected. A duplicate sentinel on the
|
|
410
|
+
# paths that already send one is harmless - the reader exits on the first.
|
|
411
|
+
if outgoing_q is not None:
|
|
412
|
+
def _stop_mqtt_thread() -> None:
|
|
413
|
+
try:
|
|
414
|
+
outgoing_q.put_nowait(None)
|
|
415
|
+
except Exception:
|
|
416
|
+
_LOGGER.debug("camera %s: could not signal MQTT thread to exit",
|
|
417
|
+
getattr(self, "device_id", "?"), exc_info=True)
|
|
418
|
+
_cleanup.callback(_stop_mqtt_thread)
|
|
419
|
+
|
|
344
420
|
# SDES path: fast_connect's wait-skips / TURN-strip destabilise the SCTP
|
|
345
421
|
# handshake (session churns ~every 60-90s -> live view drops to snapshot),
|
|
346
422
|
# so the SDES path always uses the full, stable handshake.
|
|
@@ -1052,7 +1128,12 @@ class _SdesOpenMixin:
|
|
|
1052
1128
|
# agent can gather its own srflx candidate. Append any TURN entries from
|
|
1053
1129
|
# ice_config so the camera can allocate a relay and probe our srflx/host
|
|
1054
1130
|
# candidates when direct connectivity fails (e.g. symmetric NAT).
|
|
1055
|
-
|
|
1131
|
+
# Advertise an ICE server to the camera only when we actually have a
|
|
1132
|
+
# URI for it. With AIDOT_STUN_SERVERS="" this used to put
|
|
1133
|
+
# {"Uris": []} into webrtcReq - an entry the camera must parse and
|
|
1134
|
+
# that names no server.
|
|
1135
|
+
_sdes_stun_uris = stun_server_uris()
|
|
1136
|
+
_sdes_ice_server_list = [{"Uris": _sdes_stun_uris}] if _sdes_stun_uris else []
|
|
1056
1137
|
_sdes_ice_server_list.extend(_sdes_turn_entries)
|
|
1057
1138
|
# _psk_value_req was generated before the SDP offer (see above).
|
|
1058
1139
|
# Reused here in webrtcReq and webrtcResp for consistency.
|
|
@@ -1893,20 +1974,42 @@ class _SdesOpenMixin:
|
|
|
1893
1974
|
# sends SRTP no matter how long the first-media wait runs.
|
|
1894
1975
|
# shield() so a timeout here cannot cancel the future the real answer
|
|
1895
1976
|
# await (and the DTLS-fallback path) still consume further down.
|
|
1977
|
+
# A TERMINAL refusal races the harvest. A camera at its viewer cap acks
|
|
1978
|
+
# -50002/-50015 about a second after webrtcReq and then, by definition,
|
|
1979
|
+
# never answers - so without this the refusal sat unread for the whole
|
|
1980
|
+
# _PRE_LAUNCH_ANSWER_WAIT_S before the later checks could see it, and we
|
|
1981
|
+
# launched a bridge for a stream the camera had already declined.
|
|
1896
1982
|
if not answer_fut.done():
|
|
1897
1983
|
_ans_wait_t0 = time.monotonic()
|
|
1898
1984
|
try:
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
"no webrtcResp answer within %.0fs - proceeding without ICE"
|
|
1907
|
-
" credentials (nomination will retry during the first-media"
|
|
1908
|
-
" wait)" % _PRE_LAUNCH_ANSWER_WAIT_S
|
|
1985
|
+
_harvest_waits = [asyncio.shield(answer_fut)]
|
|
1986
|
+
if terminal_error_fut is not None:
|
|
1987
|
+
_harvest_waits.append(asyncio.shield(terminal_error_fut))
|
|
1988
|
+
_h_done, _h_pending = await asyncio.wait(
|
|
1989
|
+
_harvest_waits,
|
|
1990
|
+
timeout=_PRE_LAUNCH_ANSWER_WAIT_S,
|
|
1991
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
1909
1992
|
)
|
|
1993
|
+
# Cancel the shield WRAPPERS only; the futures they guard are
|
|
1994
|
+
# owned by the MQTT handler and are still consumed below.
|
|
1995
|
+
for _h in _h_pending:
|
|
1996
|
+
_h.cancel()
|
|
1997
|
+
if terminal_error_fut is not None and terminal_error_fut.done():
|
|
1998
|
+
_code, _desc = terminal_error_fut.result()
|
|
1999
|
+
_status(f"camera refused: ack {_code} {_desc}"
|
|
2000
|
+
" - terminal, not launching the bridge")
|
|
2001
|
+
raise AidotCameraBusy(_code, _desc)
|
|
2002
|
+
if answer_fut.done():
|
|
2003
|
+
_status("webrtcResp answer harvested after %.2fs"
|
|
2004
|
+
% (time.monotonic() - _ans_wait_t0))
|
|
2005
|
+
else:
|
|
2006
|
+
_status(
|
|
2007
|
+
"no webrtcResp answer within %.0fs - proceeding without ICE"
|
|
2008
|
+
" credentials (nomination will retry during the first-media"
|
|
2009
|
+
" wait)" % _PRE_LAUNCH_ANSWER_WAIT_S
|
|
2010
|
+
)
|
|
2011
|
+
except AidotCameraBusy:
|
|
2012
|
+
raise
|
|
1910
2013
|
except Exception:
|
|
1911
2014
|
_LOGGER.debug("camera %s: swallowed exception awaiting answer",
|
|
1912
2015
|
getattr(self, "device_id", "?"), exc_info=True)
|
|
@@ -2613,6 +2716,11 @@ class _SdesOpenMixin:
|
|
|
2613
2716
|
# media packet; the keepalive watchdog reads it via SdesSession to
|
|
2614
2717
|
# restart a session the camera silently stopped feeding.
|
|
2615
2718
|
_media_progress: list = [0.0]
|
|
2719
|
+
# Shared with the bridge thread: [packets, bytes] actually forwarded to
|
|
2720
|
+
# ffmpeg. The SDES path decodes nothing in-process, so on_frame never
|
|
2721
|
+
# fires and these counters are the only in-process proof media flowed -
|
|
2722
|
+
# see SdesSession.media_stats(), which the live-validation gate reads.
|
|
2723
|
+
_media_counts: list = [0, 0]
|
|
2616
2724
|
|
|
2617
2725
|
def _bridge_fn():
|
|
2618
2726
|
nonlocal _br_first_di_logged, _br_first_srtp_logged, _br_first_req_dumped
|
|
@@ -3681,6 +3789,18 @@ class _SdesOpenMixin:
|
|
|
3681
3789
|
_rtp_hdr + _pd_plain,
|
|
3682
3790
|
('127.0.0.1', _lo_audio_port),
|
|
3683
3791
|
)
|
|
3792
|
+
# Count it: media_stats() documents
|
|
3793
|
+
# itself as "media actually forwarded
|
|
3794
|
+
# to ffmpeg by the bridge", and
|
|
3795
|
+
# scripts/live_validate.py uses it as
|
|
3796
|
+
# the SDES release gate. This is a
|
|
3797
|
+
# real forward, so omitting it made a
|
|
3798
|
+
# camera whose audio arrives on the
|
|
3799
|
+
# TUTK path under-report - far enough
|
|
3800
|
+
# to fail an otherwise healthy release.
|
|
3801
|
+
_media_progress[0] = _time_br.monotonic()
|
|
3802
|
+
_media_counts[0] += 1
|
|
3803
|
+
_media_counts[1] += len(_rtp_hdr) + len(_pd_plain)
|
|
3684
3804
|
if not _br_first_audio_logged:
|
|
3685
3805
|
_br_first_audio_logged = True
|
|
3686
3806
|
# Record the payload type this path
|
|
@@ -3921,6 +4041,8 @@ class _SdesOpenMixin:
|
|
|
3921
4041
|
_fwd_pkt, ('127.0.0.1', _btgt)
|
|
3922
4042
|
)
|
|
3923
4043
|
_media_progress[0] = _time_br.monotonic()
|
|
4044
|
+
_media_counts[0] += 1
|
|
4045
|
+
_media_counts[1] += len(_fwd_pkt)
|
|
3924
4046
|
except Exception:
|
|
3925
4047
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_bridge_fn', exc_info=True)
|
|
3926
4048
|
# Periodic ICE controlling check: re-send USE-CANDIDATE every 2.5 s.
|
|
@@ -4188,9 +4310,19 @@ class _SdesOpenMixin:
|
|
|
4188
4310
|
_early_nominated = bool(_cam_ice_ufrag and _cam_ice_pwd and _cam_ice_cands)
|
|
4189
4311
|
_media_deadline = time.monotonic() + _FIRST_MEDIA_WAIT_S
|
|
4190
4312
|
while _first_video_pt[0] is None and time.monotonic() < _media_deadline:
|
|
4191
|
-
if
|
|
4313
|
+
if terminal_error_fut is not None and terminal_error_fut.done():
|
|
4314
|
+
_code, _desc = terminal_error_fut.result()
|
|
4315
|
+
_status(f"camera refused: ack {_code} {_desc}"
|
|
4316
|
+
" - terminal, abandoning the first-media wait")
|
|
4317
|
+
raise AidotCameraBusy(_code, _desc)
|
|
4318
|
+
if (not _early_nominated and answer_fut is not None
|
|
4319
|
+
and answer_fut.done() and not answer_fut.cancelled()):
|
|
4192
4320
|
# Read-only peek: the real await below still consumes this
|
|
4193
4321
|
# future and drives the answer/fallback paths unchanged.
|
|
4322
|
+
# ``cancelled()`` is checked explicitly because the answer-wait
|
|
4323
|
+
# cancels this future on timeout, and ``.exception()`` on a
|
|
4324
|
+
# cancelled future raises CancelledError - a BaseException that
|
|
4325
|
+
# the ``except Exception`` below would NOT catch.
|
|
4194
4326
|
_peek_sdp = ""
|
|
4195
4327
|
try:
|
|
4196
4328
|
if answer_fut.exception() is None:
|
|
@@ -4214,7 +4346,8 @@ class _SdesOpenMixin:
|
|
|
4214
4346
|
# Which single payload type (if any) each line can be narrowed to. Audio
|
|
4215
4347
|
# is usable only when its type was actually observed; otherwise the "0 8"
|
|
4216
4348
|
# line cannot be narrowed.
|
|
4217
|
-
if _vpt not in _SDP_VIDEO_PTS and answer_fut is not None
|
|
4349
|
+
if (_vpt not in _SDP_VIDEO_PTS and answer_fut is not None
|
|
4350
|
+
and answer_fut.done() and not answer_fut.cancelled()):
|
|
4218
4351
|
# No video packet arrived inside the window, but the camera's answer
|
|
4219
4352
|
# still names the codec it agreed to send. Narrowing on that beats
|
|
4220
4353
|
# leaving both codecs advertised: an unnarrowed video line makes
|
|
@@ -4404,7 +4537,16 @@ class _SdesOpenMixin:
|
|
|
4404
4537
|
else min(timeout, 8.0 if dtls_fallback_ok else 20.0)
|
|
4405
4538
|
)
|
|
4406
4539
|
try:
|
|
4407
|
-
answer
|
|
4540
|
+
# Race the answer against a TERMINAL refusal (-50002 max-streams /
|
|
4541
|
+
# -50015 SD-cap). Without this the SDES path is blind to a camera
|
|
4542
|
+
# that explicitly said no: it waited out its whole answer budget and
|
|
4543
|
+
# then handed a "no answer" to the DTLS fallback, which sent a second
|
|
4544
|
+
# offer and only THEN surfaced AidotCameraBusy. Measured ~48s to
|
|
4545
|
+
# report a refusal that arrived in about one second - and two of the
|
|
4546
|
+
# three validated models (A001513, A001064) take this path.
|
|
4547
|
+
answer = await _sdes_await_answer_or_terminal(
|
|
4548
|
+
answer_fut, terminal_error_fut, _sdes_answer_timeout, _status,
|
|
4549
|
+
)
|
|
4408
4550
|
_ans_sdp = answer.get("sdp", "")
|
|
4409
4551
|
_ans_mlines = [ln for ln in _ans_sdp.splitlines() if ln.startswith("m=")]
|
|
4410
4552
|
_status(
|
|
@@ -4709,5 +4851,8 @@ class _SdesOpenMixin:
|
|
|
4709
4851
|
cmd_chan=_cmd_chan,
|
|
4710
4852
|
talk_state=_talk_state,
|
|
4711
4853
|
media_progress=_media_progress,
|
|
4854
|
+
media_counts=_media_counts,
|
|
4712
4855
|
teardown_requested=_teardown_holder,
|
|
4856
|
+
first_video_pt=_first_video_pt,
|
|
4857
|
+
first_audio_pt=_first_audio_pt,
|
|
4713
4858
|
)
|
{python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0b2}/aidot_cameras/camera/webrtc_open.py
RENAMED
|
@@ -17,7 +17,7 @@ import logging
|
|
|
17
17
|
from typing import Callable, Optional
|
|
18
18
|
|
|
19
19
|
from ..exceptions import AidotCameraBusy, AidotCameraNotReady
|
|
20
|
-
from .constants import _LIVE_PLAY_NOT_READY
|
|
20
|
+
from .constants import _LIVE_PLAY_NOT_READY, fallback_turn_uris, stun_server_uris
|
|
21
21
|
from .models import VideoFrame
|
|
22
22
|
from .webrtc import WebRTCSession
|
|
23
23
|
from .protocol import (
|
|
@@ -705,6 +705,26 @@ class _WebRTCOpenMixin:
|
|
|
705
705
|
# AidotCameraBusy instead of timing out into a generic retry.
|
|
706
706
|
_term = _terminal_webrtc_ack(msg)
|
|
707
707
|
if _term is not None:
|
|
708
|
+
# Isolate per camera/stream BEFORE accepting the refusal. We
|
|
709
|
+
# subscribe to the account-wide topic (iot/v1/c/{user_id}/#),
|
|
710
|
+
# so without this a -50002 for a DIFFERENT camera aborts this
|
|
711
|
+
# camera's healthy open with AidotCameraBusy. That matters
|
|
712
|
+
# now that the SDES path polls terminal_error_fut across the
|
|
713
|
+
# whole first-media wait rather than reading it once.
|
|
714
|
+
# Same accept rule the answer branch below uses: an exact
|
|
715
|
+
# peerid match, or BOTH devId and dstAddr (dstAddr alone is
|
|
716
|
+
# the shared account id and matches every camera).
|
|
717
|
+
_t_pid = inner.get("peerid")
|
|
718
|
+
if not (_t_pid == peer_id
|
|
719
|
+
or (inner.get("devId") == device_id
|
|
720
|
+
and inner.get("dstAddr") == user_id)):
|
|
721
|
+
loop.call_soon_threadsafe(
|
|
722
|
+
lambda rp=_t_pid, c=_term[0]: _status(
|
|
723
|
+
f"terminal ack {c} IGNORED - belongs to another"
|
|
724
|
+
f" camera/stream: peerid {rp!r}"
|
|
725
|
+
)
|
|
726
|
+
)
|
|
727
|
+
return
|
|
708
728
|
if not terminal_error_fut.done():
|
|
709
729
|
loop.call_soon_threadsafe(terminal_error_fut.set_result, _term)
|
|
710
730
|
return
|
|
@@ -1302,6 +1322,7 @@ class _WebRTCOpenMixin:
|
|
|
1302
1322
|
rtsp_push_url=rtsp_push_url,
|
|
1303
1323
|
talk=talk,
|
|
1304
1324
|
ice_cands_seen=ice_cands_seen,
|
|
1325
|
+
terminal_error_fut=terminal_error_fut,
|
|
1305
1326
|
)
|
|
1306
1327
|
# Success: the SdesSession now owns outgoing_q + mqtt_fut and
|
|
1307
1328
|
# reaps them on stop(); clear the backstop slot so a concurrent
|
|
@@ -1372,7 +1393,11 @@ class _WebRTCOpenMixin:
|
|
|
1372
1393
|
result.append(u)
|
|
1373
1394
|
return result
|
|
1374
1395
|
|
|
1375
|
-
|
|
1396
|
+
# Omit the entry entirely when the URI list is empty (STUN disabled
|
|
1397
|
+
# via AIDOT_STUN_SERVERS=""): RTCIceServer(urls=[]) is not the same as
|
|
1398
|
+
# "no server" - it is an ICE server that advertises nothing.
|
|
1399
|
+
_stun_uris = stun_server_uris()
|
|
1400
|
+
_ice_servers = [RTCIceServer(urls=_stun_uris)] if _stun_uris else []
|
|
1376
1401
|
if ice_config_fut.done():
|
|
1377
1402
|
try:
|
|
1378
1403
|
_ice_data = ice_config_fut.result()
|
|
@@ -1478,10 +1503,10 @@ class _WebRTCOpenMixin:
|
|
|
1478
1503
|
# that are on a different network segment or behind strict NAT. The Arnoo
|
|
1479
1504
|
# TURN server (confirmed from browser webrtc_internals - ALL candidates are
|
|
1480
1505
|
# relay type) accepts connections without explicit per-session credentials.
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
if not _has_turn_in_resp:
|
|
1484
|
-
_ice_servers.append(RTCIceServer(urls=
|
|
1506
|
+
# AIDOT_TURN_SERVERS overrides the hardcoded pair (empty disables it).
|
|
1507
|
+
_arnoo_fallback = fallback_turn_uris()
|
|
1508
|
+
if not _has_turn_in_resp and _arnoo_fallback:
|
|
1509
|
+
_ice_servers.append(RTCIceServer(urls=_arnoo_fallback))
|
|
1485
1510
|
# Log the final ICE server configuration.
|
|
1486
1511
|
_stun_url = (_ice_servers[0].urls[0]
|
|
1487
1512
|
if _ice_servers and _ice_servers[0].urls
|
|
@@ -1512,9 +1537,10 @@ class _WebRTCOpenMixin:
|
|
|
1512
1537
|
RTCIceServer(urls=_su, username=_srv.username,
|
|
1513
1538
|
credential=_srv.credential)
|
|
1514
1539
|
)
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1540
|
+
_fallback_stun = stun_server_uris()
|
|
1541
|
+
_ice_servers = _stun_only or (
|
|
1542
|
+
[RTCIceServer(urls=_fallback_stun)] if _fallback_stun else []
|
|
1543
|
+
)
|
|
1518
1544
|
_status(
|
|
1519
1545
|
"AIDOT_FAST_CONNECT: stripped TURN (STUN-only, LAN-direct) -"
|
|
1520
1546
|
f" {len(_ice_servers)} ICE server(s), no relay gather stall"
|
|
@@ -635,9 +635,13 @@ class CameraClient(_UpstreamAidotClient):
|
|
|
635
635
|
_LOGGER.warning("_async_fetch_user_config: no HTTP session")
|
|
636
636
|
return
|
|
637
637
|
|
|
638
|
+
# Imported here, not at module scope: aidot_cameras.camera.__init__
|
|
639
|
+
# re-exports camera.client, which imports back through device_client -
|
|
640
|
+
# a top-level import would close that cycle during package init.
|
|
641
|
+
from .camera.constants import aidot_api_base
|
|
642
|
+
|
|
638
643
|
region = account_region(self)
|
|
639
|
-
|
|
640
|
-
url = f"{base}/commons/userConfig"
|
|
644
|
+
url = f"{aidot_api_base(region)}/commons/userConfig"
|
|
641
645
|
headers = {
|
|
642
646
|
"appid": _CLOUD_APP_ID,
|
|
643
647
|
"owner": user_id,
|
|
@@ -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.15.0b2"
|
|
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"
|
|
@@ -84,6 +84,21 @@ include = ["aidot_cameras*"]
|
|
|
84
84
|
[tool.setuptools.package-data]
|
|
85
85
|
aidot_cameras = ["py.typed"]
|
|
86
86
|
|
|
87
|
+
[tool.pytest.ini_options]
|
|
88
|
+
# Test tiers. A bare `pytest tests/` must never touch hardware: the `live`
|
|
89
|
+
# marker (real cameras on the LAN, release gate only) is deselected by
|
|
90
|
+
# default and has to be requested explicitly with `-m live`.
|
|
91
|
+
markers = [
|
|
92
|
+
"e2e: fake-lab end-to-end tests (fake cloud / MQTT broker / camera / go2rtc)",
|
|
93
|
+
"live: requires real cameras on the LAN; release-gate tier, never selected by default",
|
|
94
|
+
"mux: exercises the real ffmpeg/libavformat mux path",
|
|
95
|
+
]
|
|
96
|
+
addopts = "-m 'not live'"
|
|
97
|
+
# The e2e tier uses async fixtures (fake broker / cloud servers); auto mode
|
|
98
|
+
# keeps them working without decorating every test.
|
|
99
|
+
asyncio_mode = "auto"
|
|
100
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
101
|
+
|
|
87
102
|
[tool.coverage.run]
|
|
88
103
|
# Vendored third-party code (aiortc) is exercised via streaming, not our tests;
|
|
89
104
|
# keep it out of the coverage report so the numbers reflect our own code.
|