python-aidot-cameras 0.16.0__tar.gz → 0.17.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_aidot_cameras-0.16.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.1}/PKG-INFO +1 -1
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/client.py +134 -7
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/constants.py +7 -3
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/controls.py +38 -5
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/protocol.py +190 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/sdes.py +52 -3
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/sdes_open.py +227 -2
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/webrtc.py +17 -3
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/webrtc_open.py +41 -11
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/pyproject.toml +1 -1
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/python_aidot_cameras.egg-info/SOURCES.txt +11 -0
- python_aidot_cameras-0.17.1/tests/test_busy_backoff.py +35 -0
- python_aidot_cameras-0.17.1/tests/test_control_verdicts.py +277 -0
- python_aidot_cameras-0.17.1/tests/test_device_cmd_seq_match.py +85 -0
- python_aidot_cameras-0.17.1/tests/test_futile_keepalive.py +59 -0
- python_aidot_cameras-0.17.1/tests/test_offer_keeps_rtcp_feedback.py +137 -0
- python_aidot_cameras-0.17.1/tests/test_remb.py +96 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_resolution_persists.py +15 -2
- python_aidot_cameras-0.17.1/tests/test_sdes_offer_video_pin.py +107 -0
- python_aidot_cameras-0.17.1/tests/test_sdes_sctp_abort.py +102 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_terminal_ack.py +8 -2
- python_aidot_cameras-0.17.1/tests/test_sprop_capture_gate.py +63 -0
- python_aidot_cameras-0.17.1/tests/test_video_profile_log.py +61 -0
- python_aidot_cameras-0.17.1/tests/test_wake_gate_camera_evidence.py +120 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/LICENSE +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/README.md +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_upstream.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/ble_gateway.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/hwaccel.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/client.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/device_client.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/setup.cfg +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_answer_sdp_video_pt.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_audio_mux_offline.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_avio_request.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_avio_response_parse.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_avio_response_router.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_battery_detection.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_battery_evidence_corroboration.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_battery_skip_turn.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_ble_gateway.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_bridge_state_sentinels.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_constants_imports_resolve.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dormant_camera_cleanup.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_mux_terminal_write.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_dtls_stdout_producer.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_env_seams.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_hwaccel_probe.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_ice_peer_reflexive.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_live_validate_policy.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_media_socket_rcvbuf.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_media_wait_floor.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_peerid_reuse.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_restart_cycle.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_scripts_importable.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_avio_dispatch.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_serve_stderr_drain.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_terminal_ack_latency.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdp_narrowing_properties.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sdp_payload_type_narrowing.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_serve_loss_tolerance.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_shared_house_seam.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_sprop_staleness.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_turn_create_permission.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_viewer_present_push_mode.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_wait_or_event.py +0 -0
- {python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/tests/test_wake_readiness_retry.py +0 -0
{python_aidot_cameras-0.16.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.1
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -376,6 +376,44 @@ _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
|
+
|
|
391
|
+
#: Consecutive background sessions that deliver NO media before the keepalive
|
|
392
|
+
#: stops reopening a battery camera. Observed 2026-08-07 on an A001513: 22 opens
|
|
393
|
+
#: across ~8 hours, none of them delivering media, because the loop escalated
|
|
394
|
+
#: its backoff but had no condition under which it ever stopped. On a mains
|
|
395
|
+
#: camera that is wasteful; on a battery one it spends charge, and a unit has
|
|
396
|
+
#: already been drained to 5% that way.
|
|
397
|
+
#: 0 disables the ceiling entirely.
|
|
398
|
+
_FUTILE_KEEPALIVE_LIMIT = int(os.environ.get("AIDOT_FUTILE_KEEPALIVE_LIMIT", "5"))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _should_abandon_keepalive(no_media_streak: int, *, is_battery: bool,
|
|
402
|
+
limit: int = _FUTILE_KEEPALIVE_LIMIT) -> bool:
|
|
403
|
+
"""Should the background keepalive stop reopening this camera?
|
|
404
|
+
|
|
405
|
+
Only for battery cameras. A mains camera has no charge to protect, and the
|
|
406
|
+
same persistence is what recovers a stream after a router reboot or a
|
|
407
|
+
power-cycle - there the loop is the feature.
|
|
408
|
+
|
|
409
|
+
Scoped to the BACKGROUND loop. A view still opens a session on demand: the
|
|
410
|
+
user asking to see the camera is new information, and a retry ceiling should
|
|
411
|
+
not decide on their behalf that it cannot work.
|
|
412
|
+
"""
|
|
413
|
+
if not is_battery or limit <= 0:
|
|
414
|
+
return False
|
|
415
|
+
return no_media_streak >= limit
|
|
416
|
+
|
|
379
417
|
|
|
380
418
|
def _build_sdes_serve_cmd(
|
|
381
419
|
*,
|
|
@@ -773,6 +811,39 @@ _KEEPALIVE_WINDOW_S = 25
|
|
|
773
811
|
_KEEPALIVE_RENEW_S = 20
|
|
774
812
|
|
|
775
813
|
|
|
814
|
+
def _ack_matches_seq(msg, seq):
|
|
815
|
+
"""Is ``msg`` the device's answer to the command we sent as ``seq``?
|
|
816
|
+
|
|
817
|
+
``True`` for our own 200, the integer response code for our own non-200,
|
|
818
|
+
and ``False`` for anything that is not attributable to us - another
|
|
819
|
+
command's ack, the battery wake response, a message with no seq at all.
|
|
820
|
+
|
|
821
|
+
Correlating matters because several messages are in flight on the same four
|
|
822
|
+
wildcard subscriptions: battery cameras get a ``lowPowerActiveStateReq``
|
|
823
|
+
published alongside the write, and Home Assistant writes attributes in
|
|
824
|
+
bursts. Accepting a stranger's 200 reports that one control landed on the
|
|
825
|
+
evidence of a different one landing.
|
|
826
|
+
"""
|
|
827
|
+
if not isinstance(msg, dict) or seq is None:
|
|
828
|
+
return False
|
|
829
|
+
got = msg.get("seq")
|
|
830
|
+
if got is None:
|
|
831
|
+
return False
|
|
832
|
+
# We send a string; nothing obliges the camera to echo the same type.
|
|
833
|
+
if str(got) != str(seq):
|
|
834
|
+
return False
|
|
835
|
+
code = (msg.get("ack") or {}).get("code")
|
|
836
|
+
if code is None:
|
|
837
|
+
code = msg.get("code")
|
|
838
|
+
if code is None:
|
|
839
|
+
inner = msg.get("payload") or {}
|
|
840
|
+
if isinstance(inner, dict):
|
|
841
|
+
code = inner.get("code")
|
|
842
|
+
if code == 200:
|
|
843
|
+
return True
|
|
844
|
+
return code if isinstance(code, int) else False
|
|
845
|
+
|
|
846
|
+
|
|
776
847
|
class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
777
848
|
"""All camera/streaming methods, mixed into DeviceClient via inheritance."""
|
|
778
849
|
|
|
@@ -1902,6 +1973,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1902
1973
|
*,
|
|
1903
1974
|
timeout: float = 8.0,
|
|
1904
1975
|
ack_keyword: str = "setDevAttr",
|
|
1976
|
+
seq: "Optional[str]" = None,
|
|
1905
1977
|
) -> bool:
|
|
1906
1978
|
"""Connect MQTT, optionally wake battery camera, publish command, wait for ack.
|
|
1907
1979
|
|
|
@@ -1980,6 +2052,36 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1980
2052
|
duration=timeout,
|
|
1981
2053
|
)
|
|
1982
2054
|
|
|
2055
|
+
# Two passes, because being sure is worth more than being quick. A
|
|
2056
|
+
# response carrying OUR seq is proof; anything else on these topics is
|
|
2057
|
+
# only evidence that SOMETHING was acked - and several things are in
|
|
2058
|
+
# flight here (the battery wake goes out alongside the write, and Home
|
|
2059
|
+
# Assistant writes attributes in bursts).
|
|
2060
|
+
for topic, raw in messages:
|
|
2061
|
+
if ack_keyword and ack_keyword not in topic:
|
|
2062
|
+
continue
|
|
2063
|
+
try:
|
|
2064
|
+
msg = _json.loads(raw)
|
|
2065
|
+
except Exception:
|
|
2066
|
+
continue
|
|
2067
|
+
mine = _ack_matches_seq(msg, seq)
|
|
2068
|
+
if mine is True:
|
|
2069
|
+
_LOGGER.debug("device cmd ack 200 for seq=%s: topic=%s", seq, topic)
|
|
2070
|
+
return True
|
|
2071
|
+
if mine is not False:
|
|
2072
|
+
# The camera answered OUR command with a non-200. Logged, not
|
|
2073
|
+
# acted on: whether this firmware ever sends one is unknown, and
|
|
2074
|
+
# turning it into a failure on a guess would report every
|
|
2075
|
+
# working write as broken the moment the guess is wrong. If
|
|
2076
|
+
# these show up in the wild, that is the evidence needed to make
|
|
2077
|
+
# it a real error.
|
|
2078
|
+
_LOGGER.warning(
|
|
2079
|
+
"device cmd for %s answered with code %s (seq=%s, topic=%s) "
|
|
2080
|
+
"- reporting the command as sent anyway; please report this "
|
|
2081
|
+
"log line, it is the evidence needed to handle it properly",
|
|
2082
|
+
device_id, mine, seq, topic,
|
|
2083
|
+
)
|
|
2084
|
+
|
|
1983
2085
|
for topic, raw in messages:
|
|
1984
2086
|
if ack_keyword and ack_keyword not in topic:
|
|
1985
2087
|
continue
|
|
@@ -1987,14 +2089,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1987
2089
|
msg = _json.loads(raw)
|
|
1988
2090
|
except Exception:
|
|
1989
2091
|
continue
|
|
2092
|
+
if _ack_matches_seq(msg, seq) is not False:
|
|
2093
|
+
continue # already considered above
|
|
1990
2094
|
code = (msg.get("ack") or {}).get("code") or msg.get("code")
|
|
1991
2095
|
if code == 200:
|
|
1992
|
-
_LOGGER.debug("device cmd ack 200: topic=%s", topic)
|
|
2096
|
+
_LOGGER.debug("device cmd ack 200 (unattributed): topic=%s", topic)
|
|
1993
2097
|
return True
|
|
1994
2098
|
# Some firmware ACKs with code in payload.payload
|
|
1995
2099
|
inner = msg.get("payload") or {}
|
|
1996
2100
|
if isinstance(inner, dict) and inner.get("code") == 200:
|
|
1997
|
-
_LOGGER.debug(
|
|
2101
|
+
_LOGGER.debug(
|
|
2102
|
+
"device cmd ack 200 (inner, unattributed): topic=%s", topic)
|
|
1998
2103
|
return True
|
|
1999
2104
|
|
|
2000
2105
|
# Fire-and-forget fallback: the official app uses a delivery callback,
|
|
@@ -2070,7 +2175,8 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2070
2175
|
pub_topic = f"iot/v1/c/{device_id}/device/setDevAttrReq"
|
|
2071
2176
|
_LOGGER.info("setDevAttrReq: %s=%s -> %s seq=%s", attr, value, device_id, seq)
|
|
2072
2177
|
ok = await self._mqtt_device_cmd(
|
|
2073
|
-
pub_topic, payload, timeout=timeout, ack_keyword="setDevAttr"
|
|
2178
|
+
pub_topic, payload, timeout=timeout, ack_keyword="setDevAttr",
|
|
2179
|
+
seq=seq)
|
|
2074
2180
|
if ok:
|
|
2075
2181
|
# Reflect the change in local status right away (optimistic), so HA
|
|
2076
2182
|
# control entities show the new value immediately instead of waiting
|
|
@@ -3277,6 +3383,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3277
3383
|
# camera (or a fleet reconnecting at once) isn't hammered into further
|
|
3278
3384
|
# degradation / cloud rate-limiting; resets after a session that delivered
|
|
3279
3385
|
# media (see end of loop).
|
|
3386
|
+
_no_media_streak = 0
|
|
3280
3387
|
_pacer = ReconnectPacer(_MIN_DELAY, _MAX_DELAY)
|
|
3281
3388
|
|
|
3282
3389
|
# Adaptive fast-with-fallback (default on): the first open tries the fast
|
|
@@ -3352,14 +3459,17 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3352
3459
|
# wake-then-sleep loop that serves nothing. Wait out the release
|
|
3353
3460
|
# window instead. (The DTLS loops have always done this; the SDES
|
|
3354
3461
|
# loop silently did not.)
|
|
3462
|
+
#
|
|
3463
|
+
# That release window is now measured rather than assumed: 8s is
|
|
3464
|
+
# enough, 2s is not. See _BUSY_BACKOFF_S.
|
|
3355
3465
|
self._fast_attempt_override = None
|
|
3356
3466
|
_LOGGER.warning(
|
|
3357
3467
|
"SDES keepalive: camera %s refused the stream (%s) - "
|
|
3358
3468
|
"backing off %.0fs so its sessions can be released",
|
|
3359
|
-
self.device_id, busy,
|
|
3469
|
+
self.device_id, busy, _BUSY_BACKOFF_S,
|
|
3360
3470
|
)
|
|
3361
3471
|
try:
|
|
3362
|
-
await asyncio.sleep(
|
|
3472
|
+
await asyncio.sleep(_BUSY_BACKOFF_S)
|
|
3363
3473
|
except asyncio.CancelledError:
|
|
3364
3474
|
return
|
|
3365
3475
|
continue
|
|
@@ -3496,6 +3606,23 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3496
3606
|
_loop_peer_id = self.generate_webrtc_peer_id(
|
|
3497
3607
|
live_type=2, stream_id=0, sdes=True)
|
|
3498
3608
|
_peer_reuses = 0
|
|
3609
|
+
_no_media_streak = 0
|
|
3610
|
+
else:
|
|
3611
|
+
_no_media_streak += 1
|
|
3612
|
+
if _should_abandon_keepalive(
|
|
3613
|
+
_no_media_streak, is_battery=self.is_battery_camera):
|
|
3614
|
+
# Every open so far has cost the camera a wake and returned
|
|
3615
|
+
# nothing. Continuing spends charge to learn the same thing
|
|
3616
|
+
# again; a view will still open a session on demand.
|
|
3617
|
+
_LOGGER.warning(
|
|
3618
|
+
"camera %s: %d consecutive keepalive sessions delivered no "
|
|
3619
|
+
"media - stopping the background keepalive to stop waking "
|
|
3620
|
+
"it. A live view will still open a session; set "
|
|
3621
|
+
"AIDOT_FUTILE_KEEPALIVE_LIMIT=0 to keep retrying.",
|
|
3622
|
+
self.device_id, _no_media_streak,
|
|
3623
|
+
)
|
|
3624
|
+
self._streaming_active = False
|
|
3625
|
+
return
|
|
3499
3626
|
if _use_fast and not _healthy and not _fast_failed:
|
|
3500
3627
|
_LOGGER.info(
|
|
3501
3628
|
"SDES adaptive[%s]: fast attempt delivered no media - "
|
|
@@ -3577,10 +3704,10 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3577
3704
|
# before trying again rather than hammering it.
|
|
3578
3705
|
_LOGGER.warning(
|
|
3579
3706
|
"Stream refused for %s (%s) - backing off %.0fs before retry",
|
|
3580
|
-
self.device_id, busy,
|
|
3707
|
+
self.device_id, busy, _BUSY_BACKOFF_S,
|
|
3581
3708
|
)
|
|
3582
3709
|
try:
|
|
3583
|
-
await asyncio.sleep(
|
|
3710
|
+
await asyncio.sleep(_BUSY_BACKOFF_S)
|
|
3584
3711
|
except asyncio.CancelledError:
|
|
3585
3712
|
return
|
|
3586
3713
|
continue
|
{python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/constants.py
RENAMED
|
@@ -131,9 +131,13 @@ _PTZ_DIR_CODES: dict = {
|
|
|
131
131
|
"zoom_in": 23, "zoom_out": 24,
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
# Live-stream resolution (AVIOCTRL_QUALITY_*). The official app's
|
|
135
|
-
#
|
|
136
|
-
#
|
|
134
|
+
# Live-stream resolution (AVIOCTRL_QUALITY_*). The official app's picker has
|
|
135
|
+
# THREE arms, not two: MAX(1), MIDDLE(5), and 0x10 = 16, whose label is
|
|
136
|
+
# _auto_resulution (HorLiveController.smali:629-631). 16 is deliberately absent
|
|
137
|
+
# here: it is outside AVIOCTRL_QUALITY, and measured 2026-08-07 it behaves like
|
|
138
|
+
# every other value - acked, and the encode unchanged in that session and the
|
|
139
|
+
# next. Adding it would offer a control that does nothing. The two arms below
|
|
140
|
+
# are the ones with enum names, kept for parity with the command the app sends. Sent as SETSTREAMCTRL (cmd 800) over the active session,
|
|
137
141
|
# payload <IB3x> = channel(0) + quality byte (SMsgAVIoctrlSetStreamCtrlReq).
|
|
138
142
|
SETSTREAMCTRL_CMD = 800
|
|
139
143
|
GETSTREAMCTRL_CMD = 802
|
{python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/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,40 @@ 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.19s and reports the new value through GETSTREAMCTRL afterwards
|
|
176
|
+
# (where it implements 802), so the command is genuinely accepted - it
|
|
177
|
+
# simply does not change the encode.
|
|
178
|
+
#
|
|
179
|
+
# Settled 2026-08-07 across both transports and every value the enum
|
|
180
|
+
# defines, not just hd/sd: an A001064 was swept over all six
|
|
181
|
+
# AVIOCTRL_QUALITY values, each followed by a session that sent nothing
|
|
182
|
+
# to catch a setting that applies only to the NEXT session. Twelve
|
|
183
|
+
# sessions, twelve times h264 1280x720, no dimension change in any of
|
|
184
|
+
# them. Four of those values had never been sent to any camera here.
|
|
185
|
+
# See docs/APP-PARITY-STATUS.md for the table.
|
|
186
|
+
#
|
|
187
|
+
# The RETURN value deliberately still means "the command went out". An
|
|
188
|
+
# A001064 answers SPEAKERSTART but does not implement GETSTREAMCTRL at
|
|
189
|
+
# all, so how much a given firmware answers is model-dependent, and
|
|
190
|
+
# turning silence into a failure would break this call on exactly the
|
|
191
|
+
# quieter cameras. Reading the reply is what we could not do before; it
|
|
192
|
+
# makes a refusal visible instead of indistinguishable from success.
|
|
193
|
+
reply = await session.async_avio_request(
|
|
194
|
+
SETSTREAMCTRL_CMD, payload,
|
|
195
|
+
response_cmd=SETSTREAMCTRL_RESP_CMD, timeout=_SETSTREAMCTRL_ACK_S,
|
|
173
196
|
)
|
|
174
|
-
|
|
197
|
+
if reply is None:
|
|
198
|
+
_LOGGER.debug(
|
|
199
|
+
"set resolution %s (quality=%d): sent, no ack within %.1fs",
|
|
200
|
+
quality, q, _SETSTREAMCTRL_ACK_S,
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
_LOGGER.debug(
|
|
204
|
+
"set resolution %s (quality=%d): camera acked %d payload=%s",
|
|
205
|
+
quality, q, reply.command, reply.payload.hex() or "<empty>",
|
|
206
|
+
)
|
|
207
|
+
return True
|
|
175
208
|
|
|
176
209
|
def _apply_pending_resolution(self) -> None:
|
|
177
210
|
"""Re-send the remembered resolution now that a session exists.
|
{python_aidot_cameras-0.16.0 → python_aidot_cameras-0.17.1}/aidot_cameras/camera/protocol.py
RENAMED
|
@@ -235,6 +235,14 @@ def _parse_alarm_event(msg: dict):
|
|
|
235
235
|
return {"device_id": dev, "type": kind, "alarm_type": code}
|
|
236
236
|
|
|
237
237
|
|
|
238
|
+
def _section_of(lines: list) -> str:
|
|
239
|
+
"""The m-section the given emitted lines currently end inside, or ""."""
|
|
240
|
+
for ln in reversed(lines):
|
|
241
|
+
if ln.startswith("m="):
|
|
242
|
+
return ln.split(" ")[0]
|
|
243
|
+
return ""
|
|
244
|
+
|
|
245
|
+
|
|
238
246
|
def _compress_sdp_for_camera(sdp: str) -> str:
|
|
239
247
|
"""Selective SDP filter matching official Java client's g.b() behaviour.
|
|
240
248
|
|
|
@@ -258,6 +266,14 @@ def _compress_sdp_for_camera(sdp: str) -> str:
|
|
|
258
266
|
seen: dict = {}
|
|
259
267
|
media_type = ""
|
|
260
268
|
before_m = True
|
|
269
|
+
# Payload types that survived narrowing. rtcp-fb is kept for these and only
|
|
270
|
+
# these: feedback describing a codec we dropped is bytes spent on something
|
|
271
|
+
# that will never be sent, in an offer whose size is why this function
|
|
272
|
+
# exists.
|
|
273
|
+
_kept_pts: set = set()
|
|
274
|
+
# rtcp-fb lines are buffered because they can appear before the rtpmap that
|
|
275
|
+
# decides whether their payload type survives.
|
|
276
|
+
_fb_lines: list = []
|
|
261
277
|
|
|
262
278
|
def keep(ln: str, key: str = "") -> None:
|
|
263
279
|
out.append(ln + "\r\n")
|
|
@@ -283,6 +299,11 @@ def _compress_sdp_for_camera(sdp: str) -> str:
|
|
|
283
299
|
if ln.startswith("a=mid:"):
|
|
284
300
|
keep(ln)
|
|
285
301
|
continue
|
|
302
|
+
if ln.startswith("a=rtcp-fb:"):
|
|
303
|
+
# Buffered, not decided here: an rtcp-fb line can precede the
|
|
304
|
+
# rtpmap that determines whether its payload type survives.
|
|
305
|
+
_fb_lines.append((media_type, ln))
|
|
306
|
+
continue
|
|
286
307
|
if ln.startswith("a=ssrc") and "cname" in ln:
|
|
287
308
|
if seen.get(f"ssrc-cname-{media_type}") is None:
|
|
288
309
|
keep(ln, f"ssrc-cname-{media_type}")
|
|
@@ -303,17 +324,26 @@ def _compress_sdp_for_camera(sdp: str) -> str:
|
|
|
303
324
|
if media_type == "m=audio":
|
|
304
325
|
if any(c in ln for c in ("opus", "PCMU", "PCMA", "AAC")):
|
|
305
326
|
keep(ln)
|
|
327
|
+
# Remember the payload type so its rtcp-fb can be kept too.
|
|
328
|
+
if ln.startswith("a=rtpmap:"):
|
|
329
|
+
try:
|
|
330
|
+
_kept_pts.add(ln.split(":")[1].split(" ")[0])
|
|
331
|
+
except Exception:
|
|
332
|
+
_LOGGER.debug("swallowed exception in %s", 'keep',
|
|
333
|
+
exc_info=True)
|
|
306
334
|
elif media_type == "m=video":
|
|
307
335
|
if "H264/90000" in ln and seen.get("H264/90000") is None:
|
|
308
336
|
keep(ln, "H264/90000")
|
|
309
337
|
try:
|
|
310
338
|
seen["H264/90000_pt"] = ln.split(":")[1].split(" ")[0]
|
|
339
|
+
_kept_pts.add(seen["H264/90000_pt"])
|
|
311
340
|
except Exception:
|
|
312
341
|
_LOGGER.debug("swallowed exception in %s", 'keep', exc_info=True)
|
|
313
342
|
elif "H265/90000" in ln and seen.get("H265/90000") is None:
|
|
314
343
|
keep(ln, "H265/90000")
|
|
315
344
|
try:
|
|
316
345
|
seen["H265/90000_pt"] = ln.split(":")[1].split(" ")[0]
|
|
346
|
+
_kept_pts.add(seen["H265/90000_pt"])
|
|
317
347
|
except Exception:
|
|
318
348
|
_LOGGER.debug("swallowed exception in %s", 'keep', exc_info=True)
|
|
319
349
|
elif "apt=" in ln:
|
|
@@ -332,6 +362,22 @@ def _compress_sdp_for_camera(sdp: str) -> str:
|
|
|
332
362
|
elif media_type == "m=application":
|
|
333
363
|
if "sctp-port" in ln:
|
|
334
364
|
keep(ln)
|
|
365
|
+
# Re-insert the surviving feedback at the end of its own media section, so
|
|
366
|
+
# the offer stays well-formed. Kept only for payload types that survived
|
|
367
|
+
# narrowing - see _kept_pts.
|
|
368
|
+
if _fb_lines:
|
|
369
|
+
kept_fb = [(mt, ln) for mt, ln in _fb_lines
|
|
370
|
+
if ln.split(":", 1)[1].split(" ")[0] in _kept_pts]
|
|
371
|
+
if kept_fb:
|
|
372
|
+
rebuilt: list = []
|
|
373
|
+
for line in out:
|
|
374
|
+
if line.startswith("m=") and rebuilt:
|
|
375
|
+
prev = _section_of(rebuilt)
|
|
376
|
+
rebuilt.extend(l + "\r\n" for mt, l in kept_fb if mt == prev)
|
|
377
|
+
rebuilt.append(line)
|
|
378
|
+
prev = _section_of(rebuilt)
|
|
379
|
+
rebuilt.extend(l + "\r\n" for mt, l in kept_fb if mt == prev)
|
|
380
|
+
out = rebuilt
|
|
335
381
|
return "".join(out)
|
|
336
382
|
|
|
337
383
|
|
|
@@ -2337,6 +2383,87 @@ class AvioResponseRouter:
|
|
|
2337
2383
|
self._waiting.pop(waiter._command, None)
|
|
2338
2384
|
|
|
2339
2385
|
|
|
2386
|
+
#: What we ask the camera to send, in bits per second. Zero disables sending
|
|
2387
|
+
#: REMB at all, which is the default.
|
|
2388
|
+
#:
|
|
2389
|
+
#: OFF by default because it was measured and did not work. An A001064 was A/B'd
|
|
2390
|
+
#: with REMB transmitting at 500 Kbps and correctly naming the video SSRC -
|
|
2391
|
+
#: receipts per session, interleaved arms against a control - and its bitrate
|
|
2392
|
+
#: did not fall. The one clean like-for-like pair had the REMB arm HIGHER than
|
|
2393
|
+
#: its control (3859 vs 3355 Kbps). No effect was demonstrated on the only model
|
|
2394
|
+
#: tested.
|
|
2395
|
+
#:
|
|
2396
|
+
#: That alone would argue for opt-in. The stronger argument is the models NOT
|
|
2397
|
+
#: tested: an A000088 or A001513 that does honour REMB would have had its
|
|
2398
|
+
#: picture capped at 500 Kbps fleet-wide, by default, on the strength of a
|
|
2399
|
+
#: measurement taken elsewhere that showed no benefit. Shipping a bitrate cap
|
|
2400
|
+
#: enabled by default, validated on one camera that ignores it, risks degrading
|
|
2401
|
+
#: precisely the cameras nobody measured.
|
|
2402
|
+
#:
|
|
2403
|
+
#: The negotiation, the encoder and the decoder are all kept and tested: the
|
|
2404
|
+
#: camera does advertise goog-remb in its answer, so this is a working
|
|
2405
|
+
#: instrument for anyone who wants to measure their own hardware. Set
|
|
2406
|
+
#: AIDOT_REMB_TARGET_BPS to a bitrate to turn it on.
|
|
2407
|
+
REMB_TARGET_BPS = int(os.environ.get("AIDOT_REMB_TARGET_BPS", "0"))
|
|
2408
|
+
|
|
2409
|
+
|
|
2410
|
+
def build_remb(sender_ssrc: int, media_ssrcs: "list", bitrate_bps: int) -> bytes:
|
|
2411
|
+
"""An RTCP REMB packet asking the sender for at most ``bitrate_bps``.
|
|
2412
|
+
|
|
2413
|
+
Format is the Google draft (draft-alvestrand-rmcat-remb) carried as a
|
|
2414
|
+
Payload-Specific Feedback message: FMT 15, PT 206, the ASCII identifier
|
|
2415
|
+
``REMB``, then a count of SSRCs and the bitrate as a 6-bit exponent with an
|
|
2416
|
+
18-bit mantissa, then the SSRCs themselves.
|
|
2417
|
+
|
|
2418
|
+
The camera negotiates ``goog-remb`` in its answer (confirmed live on an
|
|
2419
|
+
A001064 over SDES), which means it will accept this. It has no effect until
|
|
2420
|
+
one is actually sent - negotiation alone changes nothing, which is why this
|
|
2421
|
+
is a separate step from keeping the attribute in the offer.
|
|
2422
|
+
"""
|
|
2423
|
+
if bitrate_bps <= 0:
|
|
2424
|
+
raise ValueError("REMB bitrate must be positive - zero would ask the "
|
|
2425
|
+
"camera to stop sending entirely")
|
|
2426
|
+
if not media_ssrcs:
|
|
2427
|
+
raise ValueError("REMB must name at least one stream")
|
|
2428
|
+
|
|
2429
|
+
# 18-bit mantissa, 6-bit exponent: shift right until the value fits.
|
|
2430
|
+
exp = 0
|
|
2431
|
+
mantissa = int(bitrate_bps)
|
|
2432
|
+
while mantissa > 0x3FFFF:
|
|
2433
|
+
mantissa >>= 1
|
|
2434
|
+
exp += 1
|
|
2435
|
+
if exp > 63:
|
|
2436
|
+
raise ValueError("REMB bitrate too large to encode")
|
|
2437
|
+
|
|
2438
|
+
header = struct.pack(
|
|
2439
|
+
"!BBH",
|
|
2440
|
+
0x80 | 15, # V=2, P=0, FMT=15
|
|
2441
|
+
206, # PT: payload-specific feedback
|
|
2442
|
+
0, # length, filled in below
|
|
2443
|
+
)
|
|
2444
|
+
body = (struct.pack("!II", sender_ssrc & 0xFFFFFFFF, 0)
|
|
2445
|
+
+ b"REMB"
|
|
2446
|
+
+ struct.pack("!BBBB",
|
|
2447
|
+
len(media_ssrcs) & 0xFF,
|
|
2448
|
+
((exp << 2) | (mantissa >> 16)) & 0xFF,
|
|
2449
|
+
(mantissa >> 8) & 0xFF,
|
|
2450
|
+
mantissa & 0xFF)
|
|
2451
|
+
+ b"".join(struct.pack("!I", s & 0xFFFFFFFF) for s in media_ssrcs))
|
|
2452
|
+
pkt = header + body
|
|
2453
|
+
# RTCP length is in 32-bit words, minus one.
|
|
2454
|
+
return pkt[:2] + struct.pack("!H", len(pkt) // 4 - 1) + pkt[4:]
|
|
2455
|
+
|
|
2456
|
+
|
|
2457
|
+
def decode_remb_bitrate(pkt: bytes) -> int:
|
|
2458
|
+
"""The bitrate a REMB packet asks for. For tests and diagnostics."""
|
|
2459
|
+
# Layout: 0-3 header, 4-7 sender SSRC, 8-11 media SSRC, 12-15 "REMB",
|
|
2460
|
+
# 16 num-SSRC, 17-19 exponent+mantissa, 20+ the SSRCs.
|
|
2461
|
+
b = pkt[17]
|
|
2462
|
+
exp = b >> 2
|
|
2463
|
+
mantissa = ((b & 0x03) << 16) | (pkt[18] << 8) | pkt[19]
|
|
2464
|
+
return mantissa << exp
|
|
2465
|
+
|
|
2466
|
+
|
|
2340
2467
|
def _avio_cmd_id(frame: bytes) -> "Optional[int]":
|
|
2341
2468
|
"""The command id of an AVIO frame, or None if it does not look like one.
|
|
2342
2469
|
|
|
@@ -2356,6 +2483,69 @@ def _avio_cmd_id(frame: bytes) -> "Optional[int]":
|
|
|
2356
2483
|
AVIO_RESPONSE_TIMEOUT_S = 2.0
|
|
2357
2484
|
|
|
2358
2485
|
|
|
2486
|
+
#: How long to wait for the camera's SPEAKERSTART ack before opening the mic
|
|
2487
|
+
#: anyway. Measured 0.01-0.86s across both transports; this is generous enough
|
|
2488
|
+
#: to cover a slow SDES open without making a press-to-talk feel stuck.
|
|
2489
|
+
SPEAKER_ACK_TIMEOUT_S = 2.0
|
|
2490
|
+
|
|
2491
|
+
#: Ack payloads observed for SPEAKERSTART, all of them on speakers that opened:
|
|
2492
|
+
#: 0x0064 (M3 Pro / DTLS, PTZ / SDES, and an L2) and 0x00c8 (the same L2, on the
|
|
2493
|
+
#: very next session). One camera returning both across consecutive successful
|
|
2494
|
+
#: opens is what settles it - the payload is not a verdict, so there is nothing
|
|
2495
|
+
#: here to read as a refusal.
|
|
2496
|
+
_SPEAKER_ACKS_SEEN = (b"\x00\x64", b"\x00\xc8")
|
|
2497
|
+
|
|
2498
|
+
|
|
2499
|
+
async def _speaker_opened(session, cmd: int, resp_cmd: int, timeout: float) -> bool:
|
|
2500
|
+
"""Send SPEAKERSTART and decide whether the camera opened its speaker.
|
|
2501
|
+
|
|
2502
|
+
Everything currently counts as success, and that is the honest position
|
|
2503
|
+
rather than a placeholder. Silence does, because an A001064 answers 848 but
|
|
2504
|
+
does not implement 802 at all - a firmware with no response for a command is
|
|
2505
|
+
a real and common state, and failing on it would remove two-way audio from
|
|
2506
|
+
every camera quieter than the one measured. And any ack does, because no
|
|
2507
|
+
refusal has ever been observed: the payload varies across models AND across
|
|
2508
|
+
consecutive sessions on one camera, so it carries no verdict to read.
|
|
2509
|
+
|
|
2510
|
+
An earlier version treated 0x0064 as "the" success value and any other
|
|
2511
|
+
two-byte payload as a refusal. That was a discriminator invented from a
|
|
2512
|
+
single sample, and it would have disabled talk outright on an L2, which
|
|
2513
|
+
answers 0x00c8 about half the time. The payload is logged instead, so a
|
|
2514
|
+
genuine refusal can be recognised from evidence when one appears.
|
|
2515
|
+
"""
|
|
2516
|
+
waiter = session._avio_responses.expect(resp_cmd)
|
|
2517
|
+
try:
|
|
2518
|
+
sent = session._avio_cmd(cmd, b"\x00" * 8)
|
|
2519
|
+
except Exception:
|
|
2520
|
+
sent = False
|
|
2521
|
+
if not sent:
|
|
2522
|
+
waiter.cancel()
|
|
2523
|
+
return True # no channel to refuse on; unchanged from before
|
|
2524
|
+
return await _speaker_ack_accepted(waiter, timeout)
|
|
2525
|
+
|
|
2526
|
+
|
|
2527
|
+
async def _speaker_ack_accepted(waiter, timeout: float) -> bool:
|
|
2528
|
+
"""Read a SPEAKERSTART ack, defaulting to acceptance on anything unfamiliar.
|
|
2529
|
+
|
|
2530
|
+
Shared by both transports because the decision is the same, even though the
|
|
2531
|
+
send is not: on SDES the command leaves from the bridge thread and this only
|
|
2532
|
+
ever waits.
|
|
2533
|
+
"""
|
|
2534
|
+
reply = await waiter.result(timeout)
|
|
2535
|
+
if reply is None:
|
|
2536
|
+
_LOGGER.debug("SPEAKERSTART: no ack within %.1fs - treating as opened", timeout)
|
|
2537
|
+
return True
|
|
2538
|
+
if reply.payload not in _SPEAKER_ACKS_SEEN:
|
|
2539
|
+
_LOGGER.info(
|
|
2540
|
+
"SPEAKERSTART acked with an unrecorded payload %s - treating the "
|
|
2541
|
+
"speaker as open. Please report this: the payloads seen so far all "
|
|
2542
|
+
"belong to speakers that opened, so a genuine refusal has never "
|
|
2543
|
+
"been identified and this may be the first",
|
|
2544
|
+
reply.payload.hex() or "<empty>",
|
|
2545
|
+
)
|
|
2546
|
+
return True
|
|
2547
|
+
|
|
2548
|
+
|
|
2359
2549
|
class AvioRequestMixin:
|
|
2360
2550
|
"""Ask an AVIO command and wait for the camera's reply.
|
|
2361
2551
|
|
|
@@ -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().
|