python-aidot-cameras 0.16.0__tar.gz → 0.17.0__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.16.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.0}/PKG-INFO +1 -1
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/client.py +90 -7
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/controls.py +30 -5
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/protocol.py +63 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/sdes.py +52 -3
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/sdes_open.py +43 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/webrtc.py +17 -3
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/pyproject.toml +1 -1
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/SOURCES.txt +4 -0
- python_aidot_cameras-0.17.0/tests/test_busy_backoff.py +35 -0
- python_aidot_cameras-0.17.0/tests/test_control_verdicts.py +277 -0
- python_aidot_cameras-0.17.0/tests/test_device_cmd_seq_match.py +85 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_resolution_persists.py +15 -2
- python_aidot_cameras-0.17.0/tests/test_sdes_sctp_abort.py +102 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_terminal_ack.py +8 -2
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/LICENSE +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/README.md +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_upstream.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/ble_gateway.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/constants.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/hwaccel.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/webrtc_open.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/client.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/device_client.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/setup.cfg +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_answer_sdp_video_pt.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_audio_mux_offline.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_avio_request.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_avio_response_parse.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_avio_response_router.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_battery_detection.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_battery_evidence_corroboration.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_battery_skip_turn.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_ble_gateway.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_bridge_state_sentinels.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_constants_imports_resolve.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dormant_camera_cleanup.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_mux_terminal_write.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_dtls_stdout_producer.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_env_seams.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_hwaccel_probe.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_ice_peer_reflexive.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_live_validate_policy.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_media_socket_rcvbuf.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_media_wait_floor.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_peerid_reuse.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_restart_cycle.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_scripts_importable.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_avio_dispatch.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_stderr_drain.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_terminal_ack_latency.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdp_narrowing_properties.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sdp_payload_type_narrowing.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_serve_loss_tolerance.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_shared_house_seam.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_sprop_staleness.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_turn_create_permission.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_viewer_present_push_mode.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_wait_or_event.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/tests/test_wake_readiness_retry.py +0 -0
{python_aidot_cameras-0.16.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.0}/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.17.0
|
|
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
|
|
@@ -376,6 +376,18 @@ _SERVE_REORDER_QUEUE = int(os.environ.get("AIDOT_SERVE_REORDER_QUEUE", "500"))
|
|
|
376
376
|
#: failure this is meant to prevent.
|
|
377
377
|
_SERVE_MAX_DELAY_US = int(os.environ.get("AIDOT_SERVE_MAX_DELAY_US", "500000"))
|
|
378
378
|
|
|
379
|
+
#: How long to wait after a camera answers -50002 / -50015 ("no free session").
|
|
380
|
+
#: Both loops used to wait _MAX_DELAY (300s) on the belief that the camera
|
|
381
|
+
#: "releases slowly". Measured 2026-08-07 on an A001064: reopening 2s after a
|
|
382
|
+
#: close is refused, 8s reopens cleanly, every time - and that was minting a
|
|
383
|
+
#: fresh peerid each attempt, the case the old comment warned about. A camera
|
|
384
|
+
#: that clears in seconds paired with a five-minute refusal to retry is
|
|
385
|
+
#: indistinguishable from one that needs a long rest, which is the likeliest
|
|
386
|
+
#: origin of this project's "battery cameras need 30-45 minutes" rule.
|
|
387
|
+
#: Still a real wait: retrying instantly would hammer a camera that genuinely
|
|
388
|
+
#: has none free, and on a battery model risks a wake-then-sleep loop.
|
|
389
|
+
_BUSY_BACKOFF_S = float(os.environ.get("AIDOT_BUSY_BACKOFF_S", "20"))
|
|
390
|
+
|
|
379
391
|
|
|
380
392
|
def _build_sdes_serve_cmd(
|
|
381
393
|
*,
|
|
@@ -773,6 +785,39 @@ _KEEPALIVE_WINDOW_S = 25
|
|
|
773
785
|
_KEEPALIVE_RENEW_S = 20
|
|
774
786
|
|
|
775
787
|
|
|
788
|
+
def _ack_matches_seq(msg, seq):
|
|
789
|
+
"""Is ``msg`` the device's answer to the command we sent as ``seq``?
|
|
790
|
+
|
|
791
|
+
``True`` for our own 200, the integer response code for our own non-200,
|
|
792
|
+
and ``False`` for anything that is not attributable to us - another
|
|
793
|
+
command's ack, the battery wake response, a message with no seq at all.
|
|
794
|
+
|
|
795
|
+
Correlating matters because several messages are in flight on the same four
|
|
796
|
+
wildcard subscriptions: battery cameras get a ``lowPowerActiveStateReq``
|
|
797
|
+
published alongside the write, and Home Assistant writes attributes in
|
|
798
|
+
bursts. Accepting a stranger's 200 reports that one control landed on the
|
|
799
|
+
evidence of a different one landing.
|
|
800
|
+
"""
|
|
801
|
+
if not isinstance(msg, dict) or seq is None:
|
|
802
|
+
return False
|
|
803
|
+
got = msg.get("seq")
|
|
804
|
+
if got is None:
|
|
805
|
+
return False
|
|
806
|
+
# We send a string; nothing obliges the camera to echo the same type.
|
|
807
|
+
if str(got) != str(seq):
|
|
808
|
+
return False
|
|
809
|
+
code = (msg.get("ack") or {}).get("code")
|
|
810
|
+
if code is None:
|
|
811
|
+
code = msg.get("code")
|
|
812
|
+
if code is None:
|
|
813
|
+
inner = msg.get("payload") or {}
|
|
814
|
+
if isinstance(inner, dict):
|
|
815
|
+
code = inner.get("code")
|
|
816
|
+
if code == 200:
|
|
817
|
+
return True
|
|
818
|
+
return code if isinstance(code, int) else False
|
|
819
|
+
|
|
820
|
+
|
|
776
821
|
class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
777
822
|
"""All camera/streaming methods, mixed into DeviceClient via inheritance."""
|
|
778
823
|
|
|
@@ -1902,6 +1947,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1902
1947
|
*,
|
|
1903
1948
|
timeout: float = 8.0,
|
|
1904
1949
|
ack_keyword: str = "setDevAttr",
|
|
1950
|
+
seq: "Optional[str]" = None,
|
|
1905
1951
|
) -> bool:
|
|
1906
1952
|
"""Connect MQTT, optionally wake battery camera, publish command, wait for ack.
|
|
1907
1953
|
|
|
@@ -1980,6 +2026,36 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1980
2026
|
duration=timeout,
|
|
1981
2027
|
)
|
|
1982
2028
|
|
|
2029
|
+
# Two passes, because being sure is worth more than being quick. A
|
|
2030
|
+
# response carrying OUR seq is proof; anything else on these topics is
|
|
2031
|
+
# only evidence that SOMETHING was acked - and several things are in
|
|
2032
|
+
# flight here (the battery wake goes out alongside the write, and Home
|
|
2033
|
+
# Assistant writes attributes in bursts).
|
|
2034
|
+
for topic, raw in messages:
|
|
2035
|
+
if ack_keyword and ack_keyword not in topic:
|
|
2036
|
+
continue
|
|
2037
|
+
try:
|
|
2038
|
+
msg = _json.loads(raw)
|
|
2039
|
+
except Exception:
|
|
2040
|
+
continue
|
|
2041
|
+
mine = _ack_matches_seq(msg, seq)
|
|
2042
|
+
if mine is True:
|
|
2043
|
+
_LOGGER.debug("device cmd ack 200 for seq=%s: topic=%s", seq, topic)
|
|
2044
|
+
return True
|
|
2045
|
+
if mine is not False:
|
|
2046
|
+
# The camera answered OUR command with a non-200. Logged, not
|
|
2047
|
+
# acted on: whether this firmware ever sends one is unknown, and
|
|
2048
|
+
# turning it into a failure on a guess would report every
|
|
2049
|
+
# working write as broken the moment the guess is wrong. If
|
|
2050
|
+
# these show up in the wild, that is the evidence needed to make
|
|
2051
|
+
# it a real error.
|
|
2052
|
+
_LOGGER.warning(
|
|
2053
|
+
"device cmd for %s answered with code %s (seq=%s, topic=%s) "
|
|
2054
|
+
"- reporting the command as sent anyway; please report this "
|
|
2055
|
+
"log line, it is the evidence needed to handle it properly",
|
|
2056
|
+
device_id, mine, seq, topic,
|
|
2057
|
+
)
|
|
2058
|
+
|
|
1983
2059
|
for topic, raw in messages:
|
|
1984
2060
|
if ack_keyword and ack_keyword not in topic:
|
|
1985
2061
|
continue
|
|
@@ -1987,14 +2063,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1987
2063
|
msg = _json.loads(raw)
|
|
1988
2064
|
except Exception:
|
|
1989
2065
|
continue
|
|
2066
|
+
if _ack_matches_seq(msg, seq) is not False:
|
|
2067
|
+
continue # already considered above
|
|
1990
2068
|
code = (msg.get("ack") or {}).get("code") or msg.get("code")
|
|
1991
2069
|
if code == 200:
|
|
1992
|
-
_LOGGER.debug("device cmd ack 200: topic=%s", topic)
|
|
2070
|
+
_LOGGER.debug("device cmd ack 200 (unattributed): topic=%s", topic)
|
|
1993
2071
|
return True
|
|
1994
2072
|
# Some firmware ACKs with code in payload.payload
|
|
1995
2073
|
inner = msg.get("payload") or {}
|
|
1996
2074
|
if isinstance(inner, dict) and inner.get("code") == 200:
|
|
1997
|
-
_LOGGER.debug(
|
|
2075
|
+
_LOGGER.debug(
|
|
2076
|
+
"device cmd ack 200 (inner, unattributed): topic=%s", topic)
|
|
1998
2077
|
return True
|
|
1999
2078
|
|
|
2000
2079
|
# Fire-and-forget fallback: the official app uses a delivery callback,
|
|
@@ -2070,7 +2149,8 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2070
2149
|
pub_topic = f"iot/v1/c/{device_id}/device/setDevAttrReq"
|
|
2071
2150
|
_LOGGER.info("setDevAttrReq: %s=%s -> %s seq=%s", attr, value, device_id, seq)
|
|
2072
2151
|
ok = await self._mqtt_device_cmd(
|
|
2073
|
-
pub_topic, payload, timeout=timeout, ack_keyword="setDevAttr"
|
|
2152
|
+
pub_topic, payload, timeout=timeout, ack_keyword="setDevAttr",
|
|
2153
|
+
seq=seq)
|
|
2074
2154
|
if ok:
|
|
2075
2155
|
# Reflect the change in local status right away (optimistic), so HA
|
|
2076
2156
|
# control entities show the new value immediately instead of waiting
|
|
@@ -3352,14 +3432,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3352
3432
|
# wake-then-sleep loop that serves nothing. Wait out the release
|
|
3353
3433
|
# window instead. (The DTLS loops have always done this; the SDES
|
|
3354
3434
|
# loop silently did not.)
|
|
3435
|
+
#
|
|
3436
|
+
# That release window is now measured rather than assumed: 8s is
|
|
3437
|
+
# enough, 2s is not. See _BUSY_BACKOFF_S.
|
|
3355
3438
|
self._fast_attempt_override = None
|
|
3356
3439
|
_LOGGER.warning(
|
|
3357
3440
|
"SDES keepalive: camera %s refused the stream (%s) - "
|
|
3358
3441
|
"backing off %.0fs so its sessions can be released",
|
|
3359
|
-
self.device_id, busy,
|
|
3442
|
+
self.device_id, busy, _BUSY_BACKOFF_S,
|
|
3360
3443
|
)
|
|
3361
3444
|
try:
|
|
3362
|
-
await asyncio.sleep(
|
|
3445
|
+
await asyncio.sleep(_BUSY_BACKOFF_S)
|
|
3363
3446
|
except asyncio.CancelledError:
|
|
3364
3447
|
return
|
|
3365
3448
|
continue
|
|
@@ -3577,10 +3660,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3577
3660
|
# before trying again rather than hammering it.
|
|
3578
3661
|
_LOGGER.warning(
|
|
3579
3662
|
"Stream refused for %s (%s) - backing off %.0fs before retry",
|
|
3580
|
-
self.device_id, busy,
|
|
3663
|
+
self.device_id, busy, _BUSY_BACKOFF_S,
|
|
3581
3664
|
)
|
|
3582
3665
|
try:
|
|
3583
|
-
await asyncio.sleep(
|
|
3666
|
+
await asyncio.sleep(_BUSY_BACKOFF_S)
|
|
3584
3667
|
except asyncio.CancelledError:
|
|
3585
3668
|
return
|
|
3586
3669
|
continue
|
{python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/controls.py
RENAMED
|
@@ -13,6 +13,11 @@ from .constants import _PTZ_DIR_CODES, _STREAM_QUALITY, SETSTREAMCTRL_CMD
|
|
|
13
13
|
|
|
14
14
|
_LOGGER = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
|
+
#: The camera's ack for SETSTREAMCTRL (800).
|
|
17
|
+
SETSTREAMCTRL_RESP_CMD = 801
|
|
18
|
+
#: Measured 0.01-0.03s; short so a control call never holds up a view.
|
|
19
|
+
_SETSTREAMCTRL_ACK_S = 1.5
|
|
20
|
+
|
|
16
21
|
|
|
17
22
|
class _CameraControlsMixin:
|
|
18
23
|
"""Device-control setters mixed into CameraMixin."""
|
|
@@ -166,12 +171,32 @@ class _CameraControlsMixin:
|
|
|
166
171
|
"resolution %s remembered; the camera is not streaming, so it "
|
|
167
172
|
"will be applied when a session next starts", quality)
|
|
168
173
|
return True
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
174
|
+
# Ask, and read what comes back. The camera acks with 801 in
|
|
175
|
+
# 0.01-0.03s (measured 2026-08-07 on an A000088) and reports the new
|
|
176
|
+
# value through GETSTREAMCTRL afterwards, so the command is genuinely
|
|
177
|
+
# accepted - it simply does not change the encode.
|
|
178
|
+
#
|
|
179
|
+
# The RETURN value deliberately still means "the command went out". An
|
|
180
|
+
# A001064 answers SPEAKERSTART but does not implement GETSTREAMCTRL at
|
|
181
|
+
# all, so how much a given firmware answers is model-dependent, and
|
|
182
|
+
# turning silence into a failure would break this call on exactly the
|
|
183
|
+
# quieter cameras. Reading the reply is what we could not do before; it
|
|
184
|
+
# makes a refusal visible instead of indistinguishable from success.
|
|
185
|
+
reply = await session.async_avio_request(
|
|
186
|
+
SETSTREAMCTRL_CMD, payload,
|
|
187
|
+
response_cmd=SETSTREAMCTRL_RESP_CMD, timeout=_SETSTREAMCTRL_ACK_S,
|
|
173
188
|
)
|
|
174
|
-
|
|
189
|
+
if reply is None:
|
|
190
|
+
_LOGGER.debug(
|
|
191
|
+
"set resolution %s (quality=%d): sent, no ack within %.1fs",
|
|
192
|
+
quality, q, _SETSTREAMCTRL_ACK_S,
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
_LOGGER.debug(
|
|
196
|
+
"set resolution %s (quality=%d): camera acked %d payload=%s",
|
|
197
|
+
quality, q, reply.command, reply.payload.hex() or "<empty>",
|
|
198
|
+
)
|
|
199
|
+
return True
|
|
175
200
|
|
|
176
201
|
def _apply_pending_resolution(self) -> None:
|
|
177
202
|
"""Re-send the remembered resolution now that a session exists.
|
{python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/protocol.py
RENAMED
|
@@ -2356,6 +2356,69 @@ def _avio_cmd_id(frame: bytes) -> "Optional[int]":
|
|
|
2356
2356
|
AVIO_RESPONSE_TIMEOUT_S = 2.0
|
|
2357
2357
|
|
|
2358
2358
|
|
|
2359
|
+
#: How long to wait for the camera's SPEAKERSTART ack before opening the mic
|
|
2360
|
+
#: anyway. Measured 0.01-0.86s across both transports; this is generous enough
|
|
2361
|
+
#: to cover a slow SDES open without making a press-to-talk feel stuck.
|
|
2362
|
+
SPEAKER_ACK_TIMEOUT_S = 2.0
|
|
2363
|
+
|
|
2364
|
+
#: Ack payloads observed for SPEAKERSTART, all of them on speakers that opened:
|
|
2365
|
+
#: 0x0064 (M3 Pro / DTLS, PTZ / SDES, and an L2) and 0x00c8 (the same L2, on the
|
|
2366
|
+
#: very next session). One camera returning both across consecutive successful
|
|
2367
|
+
#: opens is what settles it - the payload is not a verdict, so there is nothing
|
|
2368
|
+
#: here to read as a refusal.
|
|
2369
|
+
_SPEAKER_ACKS_SEEN = (b"\x00\x64", b"\x00\xc8")
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
async def _speaker_opened(session, cmd: int, resp_cmd: int, timeout: float) -> bool:
|
|
2373
|
+
"""Send SPEAKERSTART and decide whether the camera opened its speaker.
|
|
2374
|
+
|
|
2375
|
+
Everything currently counts as success, and that is the honest position
|
|
2376
|
+
rather than a placeholder. Silence does, because an A001064 answers 848 but
|
|
2377
|
+
does not implement 802 at all - a firmware with no response for a command is
|
|
2378
|
+
a real and common state, and failing on it would remove two-way audio from
|
|
2379
|
+
every camera quieter than the one measured. And any ack does, because no
|
|
2380
|
+
refusal has ever been observed: the payload varies across models AND across
|
|
2381
|
+
consecutive sessions on one camera, so it carries no verdict to read.
|
|
2382
|
+
|
|
2383
|
+
An earlier version treated 0x0064 as "the" success value and any other
|
|
2384
|
+
two-byte payload as a refusal. That was a discriminator invented from a
|
|
2385
|
+
single sample, and it would have disabled talk outright on an L2, which
|
|
2386
|
+
answers 0x00c8 about half the time. The payload is logged instead, so a
|
|
2387
|
+
genuine refusal can be recognised from evidence when one appears.
|
|
2388
|
+
"""
|
|
2389
|
+
waiter = session._avio_responses.expect(resp_cmd)
|
|
2390
|
+
try:
|
|
2391
|
+
sent = session._avio_cmd(cmd, b"\x00" * 8)
|
|
2392
|
+
except Exception:
|
|
2393
|
+
sent = False
|
|
2394
|
+
if not sent:
|
|
2395
|
+
waiter.cancel()
|
|
2396
|
+
return True # no channel to refuse on; unchanged from before
|
|
2397
|
+
return await _speaker_ack_accepted(waiter, timeout)
|
|
2398
|
+
|
|
2399
|
+
|
|
2400
|
+
async def _speaker_ack_accepted(waiter, timeout: float) -> bool:
|
|
2401
|
+
"""Read a SPEAKERSTART ack, defaulting to acceptance on anything unfamiliar.
|
|
2402
|
+
|
|
2403
|
+
Shared by both transports because the decision is the same, even though the
|
|
2404
|
+
send is not: on SDES the command leaves from the bridge thread and this only
|
|
2405
|
+
ever waits.
|
|
2406
|
+
"""
|
|
2407
|
+
reply = await waiter.result(timeout)
|
|
2408
|
+
if reply is None:
|
|
2409
|
+
_LOGGER.debug("SPEAKERSTART: no ack within %.1fs - treating as opened", timeout)
|
|
2410
|
+
return True
|
|
2411
|
+
if reply.payload not in _SPEAKER_ACKS_SEEN:
|
|
2412
|
+
_LOGGER.info(
|
|
2413
|
+
"SPEAKERSTART acked with an unrecorded payload %s - treating the "
|
|
2414
|
+
"speaker as open. Please report this: the payloads seen so far all "
|
|
2415
|
+
"belong to speakers that opened, so a genuine refusal has never "
|
|
2416
|
+
"been identified and this may be the first",
|
|
2417
|
+
reply.payload.hex() or "<empty>",
|
|
2418
|
+
)
|
|
2419
|
+
return True
|
|
2420
|
+
|
|
2421
|
+
|
|
2359
2422
|
class AvioRequestMixin:
|
|
2360
2423
|
"""Ask an AVIO command and wait for the camera's reply.
|
|
2361
2424
|
|
|
@@ -9,11 +9,25 @@ import asyncio
|
|
|
9
9
|
import logging
|
|
10
10
|
from typing import Callable, Optional
|
|
11
11
|
|
|
12
|
-
from .constants import SDES_TALK_PUMP_IDLE_TICK
|
|
13
|
-
from .protocol import
|
|
12
|
+
from .constants import SDES_SPEAKERSTART_DELAY, SDES_TALK_PUMP_IDLE_TICK
|
|
13
|
+
from .protocol import (
|
|
14
|
+
SPEAKER_ACK_TIMEOUT_S,
|
|
15
|
+
AvioRequestMixin,
|
|
16
|
+
AvioResponseRouter,
|
|
17
|
+
_speaker_ack_accepted,
|
|
18
|
+
)
|
|
14
19
|
|
|
15
20
|
_LOGGER = logging.getLogger(__name__)
|
|
16
21
|
|
|
22
|
+
#: The bridge does not send SPEAKERSTART the moment talk starts - it waits
|
|
23
|
+
#: SDES_SPEAKERSTART_DELAY after the command channel is up, because a
|
|
24
|
+
#: SPEAKERSTART sent too early is ignored and never acked. The ack budget
|
|
25
|
+
#: has to cover that delay as well as the round trip, or the wait expires
|
|
26
|
+
#: before the command has even left and every press-to-talk pays the full
|
|
27
|
+
#: timeout for no verdict.
|
|
28
|
+
SDES_SPEAKER_ACK_TIMEOUT_S = SDES_SPEAKERSTART_DELAY + SPEAKER_ACK_TIMEOUT_S
|
|
29
|
+
|
|
30
|
+
|
|
17
31
|
|
|
18
32
|
class SdesSession(AvioRequestMixin):
|
|
19
33
|
"""Active SDES-SRTP stream session managed by an ffmpeg subprocess.
|
|
@@ -41,6 +55,7 @@ class SdesSession(AvioRequestMixin):
|
|
|
41
55
|
first_audio_pt=None,
|
|
42
56
|
device_id=None,
|
|
43
57
|
responses=None,
|
|
58
|
+
abort_chan=None,
|
|
44
59
|
) -> None:
|
|
45
60
|
# Which camera this session belongs to, for logging only. Optional so
|
|
46
61
|
# an existing caller that does not pass it still works; the logs then
|
|
@@ -87,6 +102,12 @@ class SdesSession(AvioRequestMixin):
|
|
|
87
102
|
self._avio_responses = (
|
|
88
103
|
responses if responses is not None else AvioResponseRouter()
|
|
89
104
|
)
|
|
105
|
+
# Mutable one-element list the bridge fills with a callable that sends
|
|
106
|
+
# an SCTP ABORT. Unlike SPEAKERSTART this is safe to call from here:
|
|
107
|
+
# ABORT carries no TSN, so there is nothing for it to race, and it is
|
|
108
|
+
# the last thing the association ever sees. None when the caller
|
|
109
|
+
# supplied no holder - teardown then behaves exactly as it did before.
|
|
110
|
+
self._abort_chan = abort_chan if abort_chan is not None else [None]
|
|
90
111
|
# Shared talk state (dict) for outbound two-way audio, or None when the
|
|
91
112
|
# session was not opened talk-capable (offer stayed recvonly). Populated
|
|
92
113
|
# by the bridge (camera audio addr) and by async_start_talk (provider).
|
|
@@ -138,13 +159,24 @@ class SdesSession(AvioRequestMixin):
|
|
|
138
159
|
# SPEAKERSTART(848) is sent by the BRIDGE thread (not here) once the SCTP
|
|
139
160
|
# command channel is up - all SCTP DATA must stay on one thread or the
|
|
140
161
|
# command races the heartbeat's TSN and the camera drops it.
|
|
162
|
+
# Register interest BEFORE the pump starts, so the ack cannot arrive
|
|
163
|
+
# while nothing is listening. Note this does NOT send 848 - the bridge
|
|
164
|
+
# thread does, once want_speaker is set. Moving the send here would put
|
|
165
|
+
# SCTP DATA on a second thread, racing the heartbeat's TSN.
|
|
166
|
+
waiter = self._avio_responses.expect(851)
|
|
141
167
|
if self._talk_thread is None or not self._talk_thread.is_alive():
|
|
142
168
|
import threading
|
|
143
169
|
self._talk_thread = threading.Thread(
|
|
144
170
|
target=_run_sdes_talk_pump, args=(self._talk_state,), daemon=True
|
|
145
171
|
)
|
|
146
172
|
self._talk_thread.start()
|
|
147
|
-
|
|
173
|
+
if await _speaker_ack_accepted(waiter, SDES_SPEAKER_ACK_TIMEOUT_S):
|
|
174
|
+
return True
|
|
175
|
+
# The camera refused. Stop the microphone rather than stream viewer
|
|
176
|
+
# audio at a speaker that never opened.
|
|
177
|
+
self._talk_state["want_speaker"] = False
|
|
178
|
+
self._talk_state["provider"] = None
|
|
179
|
+
return False
|
|
148
180
|
|
|
149
181
|
async def async_stop_talk(self) -> bool:
|
|
150
182
|
"""End two-way audio: close the camera speaker and stop sending audio."""
|
|
@@ -279,6 +311,23 @@ class SdesSession(AvioRequestMixin):
|
|
|
279
311
|
await asyncio.sleep(0.3)
|
|
280
312
|
self._talk_state["stop"] = True
|
|
281
313
|
self._talk_state["provider"] = None
|
|
314
|
+
# Close the SCTP association BEFORE anything else, while the bridge
|
|
315
|
+
# loop that sends it is still running - it dies with ffmpeg. Measured
|
|
316
|
+
# 2026-08-07: reopening 2s after a close is refused with -50002 while 8s
|
|
317
|
+
# is fine, because the camera holds the session until it works out we
|
|
318
|
+
# have gone. We were the only one of the three implementations that
|
|
319
|
+
# never said so; the official app disposes its data channel and our own
|
|
320
|
+
# DTLS path sends this same chunk.
|
|
321
|
+
_abort = self._abort_chan[0]
|
|
322
|
+
if _abort is not None:
|
|
323
|
+
try:
|
|
324
|
+
if _abort():
|
|
325
|
+
_LOGGER.debug("camera %s: sent SCTP ABORT at teardown",
|
|
326
|
+
self._device_id)
|
|
327
|
+
except Exception:
|
|
328
|
+
# Never let closing politely stop us closing at all.
|
|
329
|
+
_LOGGER.debug("camera %s: swallowed exception in %s",
|
|
330
|
+
self._device_id, 'stop', exc_info=True)
|
|
282
331
|
# Flag this as a locally-initiated teardown BEFORE signalling ffmpeg, so
|
|
283
332
|
# the bridge thread's observe loop never races a look at a stale False
|
|
284
333
|
# if it polls the exit code immediately after terminate()/kill().
|
{python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/sdes_open.py
RENAMED
|
@@ -48,6 +48,22 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
48
48
|
_MEDIA_RCVBUF_BYTES = 4 * 1024 * 1024
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def _sctp_abort_chunk() -> bytes:
|
|
52
|
+
"""An SCTP ABORT chunk (RFC 4960 s3.3.7): type 6, T=0, no parameters.
|
|
53
|
+
|
|
54
|
+
Sent at teardown so the camera learns the association is over rather than
|
|
55
|
+
timing it out. Measured 2026-08-07: reopening 2s after a close is refused
|
|
56
|
+
with -50002 (session-exceed) while 8s is fine, and we were the only one of
|
|
57
|
+
the three implementations saying nothing - the official app disposes its
|
|
58
|
+
data channel and our own DTLS path (aiortc) sends this same chunk.
|
|
59
|
+
|
|
60
|
+
T=0 because we hold the peer's verification tag and send it in the common
|
|
61
|
+
header; the T bit is for an endpoint reflecting a tag it does not have.
|
|
62
|
+
"""
|
|
63
|
+
import struct as _st_ab
|
|
64
|
+
return _st_ab.pack("!BBH", 6, 0, 4)
|
|
65
|
+
|
|
66
|
+
|
|
51
67
|
def _dispatch_sctp_avio(responses, payload) -> bool:
|
|
52
68
|
"""Offer an inbound SCTP DATA payload to the AVIO response router.
|
|
53
69
|
|
|
@@ -2793,6 +2809,9 @@ class _SdesOpenMixin:
|
|
|
2793
2809
|
# once the SCTP DataChannel is established. SdesSession reads it via
|
|
2794
2810
|
# _cmd_chan[0] to dispatch PTZ / IOCtrl commands from the main thread.
|
|
2795
2811
|
_cmd_chan: list = [None]
|
|
2812
|
+
# Bridge installs the SCTP ABORT sender here once the command
|
|
2813
|
+
# channel is up; teardown calls it to close the association.
|
|
2814
|
+
_abort_chan: list = [None]
|
|
2796
2815
|
# The other direction: matches the camera's AVIO replies to the commands
|
|
2797
2816
|
# that asked for them. Created here, not in SdesSession, because the
|
|
2798
2817
|
# bridge thread starts dispatching into it before the session object
|
|
@@ -3153,7 +3172,30 @@ class _SdesOpenMixin:
|
|
|
3153
3172
|
except Exception:
|
|
3154
3173
|
_LOGGER.debug("camera %s: swallowed exception in %s", getattr(self, "device_id", "?"), '_persistent_sdes_cmd', exc_info=True)
|
|
3155
3174
|
|
|
3175
|
+
def _persistent_sdes_abort():
|
|
3176
|
+
"""ABORT the association from the same socket the commands use.
|
|
3177
|
+
|
|
3178
|
+
Lives here rather than in the bridge's main
|
|
3179
|
+
loop because this is where the encrypted
|
|
3180
|
+
SCTP context actually is - the dcep socket,
|
|
3181
|
+
the peer tag and the encrypt helper. An
|
|
3182
|
+
earlier attempt to send it from the media
|
|
3183
|
+
loop referenced none of them and would not
|
|
3184
|
+
have compiled.
|
|
3185
|
+
"""
|
|
3186
|
+
_csock = _sctp.get('dcep_sock')
|
|
3187
|
+
_csrc = _sctp.get('dcep_src')
|
|
3188
|
+
if _csock is None or _csrc is None:
|
|
3189
|
+
return False
|
|
3190
|
+
_csock.sendto(
|
|
3191
|
+
_enc_c8_sctp(_sctp_pkt(_sctp['peer_tag'],
|
|
3192
|
+
_sctp_abort_chunk())),
|
|
3193
|
+
_csrc,
|
|
3194
|
+
)
|
|
3195
|
+
return True
|
|
3196
|
+
|
|
3156
3197
|
_cmd_chan[0] = _persistent_sdes_cmd
|
|
3198
|
+
_abort_chan[0] = _persistent_sdes_abort
|
|
3157
3199
|
except Exception as _dw_e:
|
|
3158
3200
|
_status(f"SDES DC: DCEP_WAIT LIVING err: {_dw_e}")
|
|
3159
3201
|
|
|
@@ -5024,4 +5066,5 @@ class _SdesOpenMixin:
|
|
|
5024
5066
|
first_audio_pt=_first_audio_pt,
|
|
5025
5067
|
device_id=getattr(self, "device_id", None),
|
|
5026
5068
|
responses=_avio_responses,
|
|
5069
|
+
abort_chan=_abort_chan,
|
|
5027
5070
|
)
|
|
@@ -12,7 +12,12 @@ import struct
|
|
|
12
12
|
import time
|
|
13
13
|
from typing import Any, Callable, Optional
|
|
14
14
|
|
|
15
|
-
from .protocol import
|
|
15
|
+
from .protocol import (
|
|
16
|
+
SPEAKER_ACK_TIMEOUT_S,
|
|
17
|
+
AvioRequestMixin,
|
|
18
|
+
AvioResponseRouter,
|
|
19
|
+
_speaker_opened,
|
|
20
|
+
)
|
|
16
21
|
|
|
17
22
|
_LOGGER = logging.getLogger(__name__)
|
|
18
23
|
|
|
@@ -92,14 +97,23 @@ class WebRTCSession(AvioRequestMixin):
|
|
|
92
97
|
"""
|
|
93
98
|
if not self.talk_supported:
|
|
94
99
|
return False
|
|
100
|
+
# Ask the camera to open its speaker FIRST, and only enable the
|
|
101
|
+
# microphone once it has. The other order left the mic live and
|
|
102
|
+
# encoding for the whole round trip, so a refused speaker had already
|
|
103
|
+
# been sent viewer audio by the time we detached it. The verdict is
|
|
104
|
+
# cheap here - 0.01-0.38s measured on an A000088.
|
|
105
|
+
#
|
|
106
|
+
# IOTYPE_USER_IPCAM_SPEAKERSTART = 848 (AVIOCTRLDEFs.java), 8-byte
|
|
107
|
+
# channel=0 payload; the camera answers 851.
|
|
95
108
|
self._talk_holder["provider"] = pcm_provider
|
|
109
|
+
if not await _speaker_opened(self, 848, 851, SPEAKER_ACK_TIMEOUT_S):
|
|
110
|
+
self._talk_holder["provider"] = None
|
|
111
|
+
return False
|
|
96
112
|
try:
|
|
97
113
|
self._audio_sender.replaceTrack(self._talk_track)
|
|
98
114
|
except Exception:
|
|
99
115
|
self._talk_holder["provider"] = None
|
|
100
116
|
return False
|
|
101
|
-
# IOTYPE_USER_IPCAM_SPEAKERSTART = 848 (AVIOCTRLDEFs.java), 8-byte channel=0 payload.
|
|
102
|
-
self._avio_cmd(848, b"\x00" * 8)
|
|
103
117
|
self._talk_holder["was_active"] = True # ensure stop() releases the speaker
|
|
104
118
|
return True
|
|
105
119
|
|
|
@@ -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.17.0"
|
|
8
8
|
description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
{python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.0/python_aidot_cameras.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
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
|
|
@@ -82,8 +82,11 @@ tests/test_battery_evidence_corroboration.py
|
|
|
82
82
|
tests/test_battery_skip_turn.py
|
|
83
83
|
tests/test_ble_gateway.py
|
|
84
84
|
tests/test_bridge_state_sentinels.py
|
|
85
|
+
tests/test_busy_backoff.py
|
|
85
86
|
tests/test_constants_imports_resolve.py
|
|
87
|
+
tests/test_control_verdicts.py
|
|
86
88
|
tests/test_deferred_hardening.py
|
|
89
|
+
tests/test_device_cmd_seq_match.py
|
|
87
90
|
tests/test_device_login_guard.py
|
|
88
91
|
tests/test_device_user_info_cache.py
|
|
89
92
|
tests/test_dormant_camera_cleanup.py
|
|
@@ -143,6 +146,7 @@ tests/test_sdes_fast_liveplay.py
|
|
|
143
146
|
tests/test_sdes_ffmpeg_stderr_log.py
|
|
144
147
|
tests/test_sdes_idle_release.py
|
|
145
148
|
tests/test_sdes_open_cleanup.py
|
|
149
|
+
tests/test_sdes_sctp_abort.py
|
|
146
150
|
tests/test_sdes_serve_audio.py
|
|
147
151
|
tests/test_sdes_serve_cmd.py
|
|
148
152
|
tests/test_sdes_serve_stderr_drain.py
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""How long to wait after a camera says it has no free session.
|
|
2
|
+
|
|
3
|
+
On `-50002` both keepalive loops slept `_MAX_DELAY` - **300 seconds** - on the
|
|
4
|
+
reasoning that "the camera only releases slowly". Measured 2026-08-07 on a mains
|
|
5
|
+
A001064, that is wrong by more than an order of magnitude:
|
|
6
|
+
|
|
7
|
+
close, reopen after 2s -> -50002 refused (three times, reproducibly)
|
|
8
|
+
close, reopen after 8s -> reopened fine (every time)
|
|
9
|
+
close, reopen after 20s -> reopened fine
|
|
10
|
+
|
|
11
|
+
The probe minted a fresh peerid on every attempt - the case the comment warned
|
|
12
|
+
about - and still recovered inside 8 seconds.
|
|
13
|
+
|
|
14
|
+
This matters beyond a slow reconnect. A camera that clears in seconds, paired
|
|
15
|
+
with a five-minute refusal to retry, is indistinguishable from a camera that
|
|
16
|
+
needs a long rest, and that appearance is most likely where the project's
|
|
17
|
+
"battery cameras need 30-45 minutes between sessions" rule came from. Three
|
|
18
|
+
back-to-back sessions on a battery L2 all streamed fine the same day.
|
|
19
|
+
|
|
20
|
+
The wait still has to be a real wait. Retrying immediately would hammer a camera
|
|
21
|
+
that genuinely has no free session, and on a battery model that risks the
|
|
22
|
+
wake-then-sleep loop the original comment was guarding against. So: comfortably
|
|
23
|
+
past the measured window, nowhere near five minutes.
|
|
24
|
+
"""
|
|
25
|
+
from aidot_cameras.camera.client import _BUSY_BACKOFF_S
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_the_wait_clears_the_measured_refusal_window():
|
|
29
|
+
"""8s reopened cleanly every time; leave real headroom above that."""
|
|
30
|
+
assert _BUSY_BACKOFF_S >= 15.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_the_wait_is_not_a_five_minute_rest():
|
|
34
|
+
"""The whole point: a camera that clears in seconds must not cost minutes."""
|
|
35
|
+
assert _BUSY_BACKOFF_S <= 60.0
|