python-aidot-cameras 0.15.8__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.15.8/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.0}/PKG-INFO +1 -1
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/client.py +90 -7
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/controls.py +30 -5
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/protocol.py +295 -1
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/sdes.py +59 -3
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/sdes_open.py +103 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/webrtc.py +26 -3
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/webrtc_open.py +13 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/pyproject.toml +1 -1
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/SOURCES.txt +8 -0
- python_aidot_cameras-0.17.0/tests/test_avio_request.py +221 -0
- python_aidot_cameras-0.17.0/tests/test_avio_response_parse.py +91 -0
- python_aidot_cameras-0.17.0/tests/test_avio_response_router.py +207 -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.15.8 → python_aidot_cameras-0.17.0}/tests/test_resolution_persists.py +15 -2
- python_aidot_cameras-0.17.0/tests/test_sdes_avio_dispatch.py +117 -0
- python_aidot_cameras-0.17.0/tests/test_sdes_sctp_abort.py +102 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_terminal_ack.py +8 -2
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/LICENSE +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/README.md +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_upstream.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/ble_gateway.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/constants.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/hwaccel.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/client.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/device_client.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/setup.cfg +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_answer_sdp_video_pt.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_audio_mux_offline.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_battery_detection.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_battery_evidence_corroboration.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_battery_skip_turn.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_ble_gateway.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_bridge_state_sentinels.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_constants_imports_resolve.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dormant_camera_cleanup.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_mux_terminal_write.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_dtls_stdout_producer.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_env_seams.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_hwaccel_probe.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_ice_peer_reflexive.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_live_validate_policy.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_media_socket_rcvbuf.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_media_wait_floor.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_peerid_reuse.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_restart_cycle.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_scripts_importable.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_serve_stderr_drain.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_terminal_ack_latency.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdp_narrowing_properties.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sdp_payload_type_narrowing.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_serve_loss_tolerance.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_shared_house_seam.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_sprop_staleness.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_turn_create_permission.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_viewer_present_push_mode.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_wait_or_event.py +0 -0
- {python_aidot_cameras-0.15.8 → python_aidot_cameras-0.17.0}/tests/test_wake_readiness_retry.py +0 -0
{python_aidot_cameras-0.15.8/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.15.8 → 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.15.8 → python_aidot_cameras-0.17.0}/aidot_cameras/camera/protocol.py
RENAMED
|
@@ -8,6 +8,7 @@ imports client.py -- the import edge is one-way (client -> protocol).
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
|
+
import concurrent.futures as _cfutures
|
|
11
12
|
import hashlib
|
|
12
13
|
import ipaddress
|
|
13
14
|
import json
|
|
@@ -22,7 +23,7 @@ import tempfile
|
|
|
22
23
|
import threading
|
|
23
24
|
import time
|
|
24
25
|
import zlib
|
|
25
|
-
from typing import Any, Callable, Optional
|
|
26
|
+
from typing import Any, Callable, Optional, NamedTuple
|
|
26
27
|
|
|
27
28
|
from .constants import TALK_PCM_FRAME_BYTES, TALK_PCM_RATE
|
|
28
29
|
|
|
@@ -2176,3 +2177,296 @@ def _dedup_bundle_candidates(sdp: str) -> str:
|
|
|
2176
2177
|
else:
|
|
2177
2178
|
result.extend(ln for ln in sec if not ln.startswith('a=candidate:'))
|
|
2178
2179
|
return '\r\n'.join(result)
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
#: Header of an AVIO control frame: seq, command, timestamp(ms), payload length,
|
|
2183
|
+
#: reserved, then 4 bytes of padding. Same layout we already build when sending.
|
|
2184
|
+
_AVIO_RESP_HDR = struct.Struct("<IIqII4x")
|
|
2185
|
+
|
|
2186
|
+
#: Bytes of header before an AVIO payload begins. Exported because the SDES
|
|
2187
|
+
#: receive path has to walk past one frame to reach the next in the same chunk.
|
|
2188
|
+
AVIO_HDR_LEN = _AVIO_RESP_HDR.size
|
|
2189
|
+
|
|
2190
|
+
|
|
2191
|
+
class AvioResponse(NamedTuple):
|
|
2192
|
+
"""A decoded AVIO control response."""
|
|
2193
|
+
|
|
2194
|
+
seq: int
|
|
2195
|
+
command: int
|
|
2196
|
+
payload: bytes
|
|
2197
|
+
|
|
2198
|
+
|
|
2199
|
+
def parse_avio_response(frame: bytes) -> "Optional[AvioResponse]":
|
|
2200
|
+
"""Decode an AVIO control response, or None if this is not a valid one.
|
|
2201
|
+
|
|
2202
|
+
Returns None rather than raising: this runs on the bridge's receive path,
|
|
2203
|
+
where anything can arrive, and a malformed frame must be ignored rather than
|
|
2204
|
+
kill the loop. A declared length that overruns the buffer is rejected too -
|
|
2205
|
+
trusting it would hand the caller a truncated payload and, for example, read
|
|
2206
|
+
a stream profile out of bytes the camera never sent.
|
|
2207
|
+
"""
|
|
2208
|
+
if not frame or len(frame) < _AVIO_RESP_HDR.size:
|
|
2209
|
+
return None
|
|
2210
|
+
seq, command, _ts, length, _reserved = _AVIO_RESP_HDR.unpack_from(frame, 0)
|
|
2211
|
+
body = frame[_AVIO_RESP_HDR.size:]
|
|
2212
|
+
if len(body) < length:
|
|
2213
|
+
return None
|
|
2214
|
+
return AvioResponse(seq=seq, command=command, payload=body[:length])
|
|
2215
|
+
|
|
2216
|
+
|
|
2217
|
+
class _AvioWaiter:
|
|
2218
|
+
"""A single outstanding question, handed back by ``AvioResponseRouter.expect``.
|
|
2219
|
+
|
|
2220
|
+
Built on a ``concurrent.futures.Future`` rather than an ``asyncio`` one on
|
|
2221
|
+
purpose: the reply is dispatched from the SDES bridge thread while the
|
|
2222
|
+
caller awaits on the event loop, and setting an asyncio future's result from
|
|
2223
|
+
a foreign thread does not wake the awaiting coroutine - the camera would
|
|
2224
|
+
look silent.
|
|
2225
|
+
"""
|
|
2226
|
+
|
|
2227
|
+
def __init__(self, router: "AvioResponseRouter", command: int) -> None:
|
|
2228
|
+
self._router = router
|
|
2229
|
+
self._command = command
|
|
2230
|
+
self._future = _cfutures.Future()
|
|
2231
|
+
|
|
2232
|
+
def cancel(self) -> None:
|
|
2233
|
+
"""Withdraw the question - used when the command could not be sent."""
|
|
2234
|
+
self._router._forget(self)
|
|
2235
|
+
|
|
2236
|
+
async def result(self, timeout: float) -> "Optional[AvioResponse]":
|
|
2237
|
+
"""The camera's reply, or None if it did not answer within ``timeout``.
|
|
2238
|
+
|
|
2239
|
+
None is a real answer, not an error: this firmware may not implement a
|
|
2240
|
+
response for every command we send, and a control call must never block
|
|
2241
|
+
a view while we find out.
|
|
2242
|
+
"""
|
|
2243
|
+
if self._future.done():
|
|
2244
|
+
# A wait that timed out cancels its own wrapper, and that
|
|
2245
|
+
# cancellation can propagate down to this future - asking again
|
|
2246
|
+
# would then raise where the first call returned None.
|
|
2247
|
+
if self._future.cancelled():
|
|
2248
|
+
return None
|
|
2249
|
+
try:
|
|
2250
|
+
return self._future.result()
|
|
2251
|
+
except Exception:
|
|
2252
|
+
return None
|
|
2253
|
+
try:
|
|
2254
|
+
return await asyncio.wait_for(
|
|
2255
|
+
asyncio.wrap_future(self._future), timeout
|
|
2256
|
+
)
|
|
2257
|
+
except TimeoutError:
|
|
2258
|
+
# Stop listening. _avio_cmd runs on the keepalive path, so a
|
|
2259
|
+
# registration left behind on every unanswered command would grow
|
|
2260
|
+
# for as long as the session lives.
|
|
2261
|
+
self._router._forget(self)
|
|
2262
|
+
return None
|
|
2263
|
+
except asyncio.CancelledError:
|
|
2264
|
+
# Cancellation is the caller's task being stopped (entity teardown,
|
|
2265
|
+
# config-entry unload), NOT the camera declining to answer. Drop
|
|
2266
|
+
# the registration, then let it through: swallowing it would leave
|
|
2267
|
+
# the caller running inside a task the loop has already given up on.
|
|
2268
|
+
self._router._forget(self)
|
|
2269
|
+
raise
|
|
2270
|
+
|
|
2271
|
+
|
|
2272
|
+
class AvioResponseRouter:
|
|
2273
|
+
"""Match inbound AVIO responses to the commands that asked for them.
|
|
2274
|
+
|
|
2275
|
+
The control channel also carries traffic nobody asked for (session-mode
|
|
2276
|
+
notifications, track switches), so "the next frame in" is not the answer to
|
|
2277
|
+
the last question out. Register interest with ``expect()`` *before* sending,
|
|
2278
|
+
then await the waiter; ``dispatch()`` is safe to call from the bridge thread
|
|
2279
|
+
and never raises or blocks it.
|
|
2280
|
+
"""
|
|
2281
|
+
|
|
2282
|
+
def __init__(self) -> None:
|
|
2283
|
+
self._lock = threading.Lock()
|
|
2284
|
+
self._waiting: "dict[int, list[_AvioWaiter]]" = {}
|
|
2285
|
+
|
|
2286
|
+
def expect(self, command: int) -> _AvioWaiter:
|
|
2287
|
+
"""Register interest in the next response carrying ``command``.
|
|
2288
|
+
|
|
2289
|
+
Call this before sending the request: the camera can answer before the
|
|
2290
|
+
sender returns, and a registration made afterwards would miss the reply.
|
|
2291
|
+
"""
|
|
2292
|
+
waiter = _AvioWaiter(self, command)
|
|
2293
|
+
with self._lock:
|
|
2294
|
+
self._waiting.setdefault(command, []).append(waiter)
|
|
2295
|
+
return waiter
|
|
2296
|
+
|
|
2297
|
+
def dispatch(self, frame: bytes) -> bool:
|
|
2298
|
+
"""Hand ``frame`` to whoever is waiting for it.
|
|
2299
|
+
|
|
2300
|
+
True if it answered an outstanding question. False - not an error - for
|
|
2301
|
+
junk, for a command nobody asked about, and for a retransmitted copy of
|
|
2302
|
+
a reply already delivered.
|
|
2303
|
+
"""
|
|
2304
|
+
try:
|
|
2305
|
+
resp = parse_avio_response(frame)
|
|
2306
|
+
if resp is None:
|
|
2307
|
+
return False
|
|
2308
|
+
with self._lock:
|
|
2309
|
+
queue = self._waiting.get(resp.command)
|
|
2310
|
+
waiter = None
|
|
2311
|
+
while queue:
|
|
2312
|
+
candidate = queue.pop(0)
|
|
2313
|
+
if not candidate._future.done():
|
|
2314
|
+
waiter = candidate
|
|
2315
|
+
break
|
|
2316
|
+
if queue is not None and not queue:
|
|
2317
|
+
self._waiting.pop(resp.command, None)
|
|
2318
|
+
if waiter is None:
|
|
2319
|
+
return False
|
|
2320
|
+
waiter._future.set_result(resp)
|
|
2321
|
+
return True
|
|
2322
|
+
except Exception:
|
|
2323
|
+
# Runs inline on the bridge's receive path; a bad frame must never
|
|
2324
|
+
# take the media loop down with it.
|
|
2325
|
+
return False
|
|
2326
|
+
|
|
2327
|
+
def _forget(self, waiter: "_AvioWaiter") -> None:
|
|
2328
|
+
with self._lock:
|
|
2329
|
+
queue = self._waiting.get(waiter._command)
|
|
2330
|
+
if not queue:
|
|
2331
|
+
return
|
|
2332
|
+
try:
|
|
2333
|
+
queue.remove(waiter)
|
|
2334
|
+
except ValueError:
|
|
2335
|
+
return
|
|
2336
|
+
if not queue:
|
|
2337
|
+
self._waiting.pop(waiter._command, None)
|
|
2338
|
+
|
|
2339
|
+
|
|
2340
|
+
def _avio_cmd_id(frame: bytes) -> "Optional[int]":
|
|
2341
|
+
"""The command id of an AVIO frame, or None if it does not look like one.
|
|
2342
|
+
|
|
2343
|
+
For the receive-path logs. Which ids this firmware actually sends is not
|
|
2344
|
+
documented anywhere we trust - the list we work from was assembled from
|
|
2345
|
+
frames seen on the wire - so naming them as they arrive is what turns the
|
|
2346
|
+
next question into a log grep instead of another live session.
|
|
2347
|
+
"""
|
|
2348
|
+
resp = parse_avio_response(bytes(frame))
|
|
2349
|
+
return None if resp is None else resp.command
|
|
2350
|
+
|
|
2351
|
+
|
|
2352
|
+
#: How long a control call waits for the camera before reporting "no reply".
|
|
2353
|
+
#: Short on purpose: these run behind Home Assistant service calls, and a
|
|
2354
|
+
#: firmware that implements the request but not the response must not be able to
|
|
2355
|
+
#: hold up a view while we find that out.
|
|
2356
|
+
AVIO_RESPONSE_TIMEOUT_S = 2.0
|
|
2357
|
+
|
|
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
|
+
|
|
2422
|
+
class AvioRequestMixin:
|
|
2423
|
+
"""Ask an AVIO command and wait for the camera's reply.
|
|
2424
|
+
|
|
2425
|
+
Mixed into both session types. ``_avio_cmd`` itself is left exactly as it
|
|
2426
|
+
was - fire-and-forget, synchronous, returns a bool - because every camera on
|
|
2427
|
+
both transports goes through it, for the speaker, the keepalive heartbeat
|
|
2428
|
+
and resolution alike. Wanting an answer is opt-in and additive.
|
|
2429
|
+
|
|
2430
|
+
A session that mixes this in must create its router in ``__init__``:
|
|
2431
|
+
dispatch arrives from the bridge thread and creating one lazily could race
|
|
2432
|
+
with the event loop and lose the reply to a second router.
|
|
2433
|
+
"""
|
|
2434
|
+
|
|
2435
|
+
_avio_responses: "AvioResponseRouter"
|
|
2436
|
+
|
|
2437
|
+
def dispatch_avio_frame(self, frame: bytes) -> bool:
|
|
2438
|
+
"""Offer an inbound control frame to whoever is waiting for it.
|
|
2439
|
+
|
|
2440
|
+
Called from the transports' receive paths (the DTLS DataChannel message
|
|
2441
|
+
handler, the SDES bridge thread). False - not an error - when nothing
|
|
2442
|
+
was waiting for it, which is the common case: the camera sends session
|
|
2443
|
+
notifications and track switches nobody asked for.
|
|
2444
|
+
"""
|
|
2445
|
+
return self._avio_responses.dispatch(frame)
|
|
2446
|
+
|
|
2447
|
+
async def async_avio_request(
|
|
2448
|
+
self,
|
|
2449
|
+
cmd: int,
|
|
2450
|
+
payload: bytes = b"",
|
|
2451
|
+
*,
|
|
2452
|
+
response_cmd: int,
|
|
2453
|
+
timeout: float = AVIO_RESPONSE_TIMEOUT_S,
|
|
2454
|
+
) -> "Optional[AvioResponse]":
|
|
2455
|
+
"""Send ``cmd`` and return the camera's ``response_cmd`` reply, or None.
|
|
2456
|
+
|
|
2457
|
+
None means the camera did not answer in time. That is a real result,
|
|
2458
|
+
not a failure: this firmware may implement a request without a response,
|
|
2459
|
+
and no reply at all points at the channel rather than the firmware.
|
|
2460
|
+
"""
|
|
2461
|
+
waiter = self._avio_responses.expect(response_cmd)
|
|
2462
|
+
# Registered BEFORE the send: a camera on the LAN can answer before the
|
|
2463
|
+
# sending call returns, and a reply that arrives with nobody listening
|
|
2464
|
+
# would be reported as silence.
|
|
2465
|
+
try:
|
|
2466
|
+
sent = self._avio_cmd(cmd, payload)
|
|
2467
|
+
except Exception:
|
|
2468
|
+
sent = False
|
|
2469
|
+
if not sent:
|
|
2470
|
+
waiter.cancel()
|
|
2471
|
+
return None
|
|
2472
|
+
return await waiter.result(timeout)
|
|
@@ -9,12 +9,27 @@ import asyncio
|
|
|
9
9
|
import logging
|
|
10
10
|
from typing import Callable, Optional
|
|
11
11
|
|
|
12
|
-
from .constants import SDES_TALK_PUMP_IDLE_TICK
|
|
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
|
+
)
|
|
13
19
|
|
|
14
20
|
_LOGGER = logging.getLogger(__name__)
|
|
15
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
|
|
16
29
|
|
|
17
|
-
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SdesSession(AvioRequestMixin):
|
|
18
33
|
"""Active SDES-SRTP stream session managed by an ffmpeg subprocess.
|
|
19
34
|
|
|
20
35
|
Obtain via ``await DeviceClient.async_open_webrtc_stream(...)`` when the
|
|
@@ -39,6 +54,8 @@ class SdesSession:
|
|
|
39
54
|
first_video_pt=None,
|
|
40
55
|
first_audio_pt=None,
|
|
41
56
|
device_id=None,
|
|
57
|
+
responses=None,
|
|
58
|
+
abort_chan=None,
|
|
42
59
|
) -> None:
|
|
43
60
|
# Which camera this session belongs to, for logging only. Optional so
|
|
44
61
|
# an existing caller that does not pass it still works; the logs then
|
|
@@ -80,6 +97,17 @@ class SdesSession:
|
|
|
80
97
|
# Mutable one-element list shared with the bridge thread. Bridge sets
|
|
81
98
|
# [0] to a callable(cmd, payload) once the SCTP channel is up.
|
|
82
99
|
self._cmd_chan = cmd_chan if cmd_chan is not None else [None]
|
|
100
|
+
# Matches inbound AVIO replies to the commands that asked for them.
|
|
101
|
+
# Created here, not lazily: the bridge thread dispatches into it.
|
|
102
|
+
self._avio_responses = (
|
|
103
|
+
responses if responses is not None else AvioResponseRouter()
|
|
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]
|
|
83
111
|
# Shared talk state (dict) for outbound two-way audio, or None when the
|
|
84
112
|
# session was not opened talk-capable (offer stayed recvonly). Populated
|
|
85
113
|
# by the bridge (camera audio addr) and by async_start_talk (provider).
|
|
@@ -131,13 +159,24 @@ class SdesSession:
|
|
|
131
159
|
# SPEAKERSTART(848) is sent by the BRIDGE thread (not here) once the SCTP
|
|
132
160
|
# command channel is up - all SCTP DATA must stay on one thread or the
|
|
133
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)
|
|
134
167
|
if self._talk_thread is None or not self._talk_thread.is_alive():
|
|
135
168
|
import threading
|
|
136
169
|
self._talk_thread = threading.Thread(
|
|
137
170
|
target=_run_sdes_talk_pump, args=(self._talk_state,), daemon=True
|
|
138
171
|
)
|
|
139
172
|
self._talk_thread.start()
|
|
140
|
-
|
|
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
|
|
141
180
|
|
|
142
181
|
async def async_stop_talk(self) -> bool:
|
|
143
182
|
"""End two-way audio: close the camera speaker and stop sending audio."""
|
|
@@ -272,6 +311,23 @@ class SdesSession:
|
|
|
272
311
|
await asyncio.sleep(0.3)
|
|
273
312
|
self._talk_state["stop"] = True
|
|
274
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)
|
|
275
331
|
# Flag this as a locally-initiated teardown BEFORE signalling ffmpeg, so
|
|
276
332
|
# the bridge thread's observe loop never races a look at a stale False
|
|
277
333
|
# if it polls the exit code immediately after terminate()/kill().
|