python-aidot-cameras 0.14.0__tar.gz → 0.15.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.14.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.15.0}/PKG-INFO +1 -1
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/client.py +32 -5
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/constants.py +52 -0
- python_aidot_cameras-0.15.0/aidot_cameras/camera/hwaccel.py +447 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/protocol.py +48 -1
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/sdes.py +31 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/sdes_open.py +171 -15
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/webrtc_open.py +35 -9
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/client.py +6 -2
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/pyproject.toml +16 -1
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/SOURCES.txt +10 -0
- python_aidot_cameras-0.15.0/tests/test_audio_mux_offline.py +147 -0
- python_aidot_cameras-0.15.0/tests/test_env_seams.py +156 -0
- python_aidot_cameras-0.15.0/tests/test_hwaccel_probe.py +303 -0
- python_aidot_cameras-0.15.0/tests/test_media_wait_floor.py +38 -0
- python_aidot_cameras-0.15.0/tests/test_restart_cycle.py +103 -0
- python_aidot_cameras-0.15.0/tests/test_scripts_importable.py +50 -0
- python_aidot_cameras-0.15.0/tests/test_sdes_terminal_ack_latency.py +138 -0
- python_aidot_cameras-0.15.0/tests/test_sdp_narrowing_properties.py +133 -0
- python_aidot_cameras-0.15.0/tests/test_sprop_staleness.py +83 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/LICENSE +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/README.md +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_upstream.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/ble_gateway.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/device_client.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/setup.cfg +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_answer_sdp_video_pt.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_battery_detection.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_battery_evidence_corroboration.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_battery_skip_turn.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_ble_gateway.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_constants_imports_resolve.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dormant_camera_cleanup.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_mux_terminal_write.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_ice_peer_reflexive.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_live_validate_policy.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_peerid_reuse.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_terminal_ack.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_sdp_payload_type_narrowing.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_shared_house_seam.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_turn_create_permission.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_viewer_present_push_mode.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_wait_or_event.py +0 -0
- {python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/tests/test_wake_readiness_retry.py +0 -0
{python_aidot_cameras-0.14.0/python_aidot_cameras.egg-info → python_aidot_cameras-0.15.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.15.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
|
|
@@ -22,9 +22,11 @@ from ..const import (
|
|
|
22
22
|
from .constants import (
|
|
23
23
|
_LEEDARSON_APP_ID,
|
|
24
24
|
_LIVE_PLAY_NOT_READY,
|
|
25
|
-
_SMARTHOME_URL_TEMPLATE,
|
|
25
|
+
_SMARTHOME_URL_TEMPLATE, # noqa: F401 - re-exported for back-compat; reads go through smarthome_base()
|
|
26
26
|
GETSTREAMCTRL_CMD, # noqa: F401 - re-exported for back-compat (public pair; unused in-module)
|
|
27
27
|
SDES_SPEAKERSTART_DELAY, # noqa: F401 - used by the sdes_open mixin; kept re-exported
|
|
28
|
+
aidot_api_base,
|
|
29
|
+
smarthome_base,
|
|
28
30
|
)
|
|
29
31
|
from .constants import ( # noqa: F401 - re-exported for device_client / back-compat
|
|
30
32
|
TALK_PCM_FRAME_BYTES,
|
|
@@ -372,6 +374,7 @@ def _build_sdes_serve_cmd(
|
|
|
372
374
|
sdes_audio: bool = False,
|
|
373
375
|
audio_gain_db: float = -8.0,
|
|
374
376
|
push_video_only: bool = False,
|
|
377
|
+
video_decoder: Optional[list] = None,
|
|
375
378
|
) -> list:
|
|
376
379
|
"""Build the ffmpeg argv for the SDES bridge serve.
|
|
377
380
|
|
|
@@ -445,10 +448,25 @@ def _build_sdes_serve_cmd(
|
|
|
445
448
|
dest_args = ["-c", "copy", *time_args, output_path]
|
|
446
449
|
else:
|
|
447
450
|
dest_args = [*time_args, "-f", "null", "/dev/null"]
|
|
451
|
+
|
|
452
|
+
# Decoder selection applies to the ``-f null`` drain alone - it is the only
|
|
453
|
+
# destination that decodes; every other one is ``-c copy``. The caller
|
|
454
|
+
# passes ready-made input arguments, either ``["-hwaccel", m]`` or
|
|
455
|
+
# ``["-c:v", name]``, and they must sit BEFORE ``-i``: after ``-i`` ffmpeg
|
|
456
|
+
# reads ``-c:v`` as an ENCODER, and ``-hwaccel`` is an input option that has
|
|
457
|
+
# no meaning there at all. Empty or None means ffmpeg chooses, as always. Keep the decode: it is what proves frames are not merely
|
|
458
|
+
# arriving but decodable, which is what catches a parameter-set mismatch
|
|
459
|
+
# that otherwise presents as a permanently black picture.
|
|
460
|
+
decode_args = (
|
|
461
|
+
list(video_decoder)
|
|
462
|
+
if video_decoder and not rtsp_push_url and not output_path
|
|
463
|
+
else []
|
|
464
|
+
)
|
|
448
465
|
return [
|
|
449
466
|
"ffmpeg", "-y",
|
|
450
467
|
"-loglevel", "warning",
|
|
451
468
|
"-protocol_whitelist", "file,rtp,udp,srtp",
|
|
469
|
+
*decode_args,
|
|
452
470
|
# 2 s analyzeduration: the camera sends SPS+PPS+IDR in the first burst;
|
|
453
471
|
# 15 s consumed nearly all packets during analysis. PLI re-arms an IDR
|
|
454
472
|
# every 5 s so parameters always re-arrive.
|
|
@@ -1156,7 +1174,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1156
1174
|
|
|
1157
1175
|
@property
|
|
1158
1176
|
def _smarthome_base(self) -> str:
|
|
1159
|
-
return
|
|
1177
|
+
return smarthome_base(self._region)
|
|
1160
1178
|
|
|
1161
1179
|
def set_token_refresh_cb(self, cb: "Callable") -> None:
|
|
1162
1180
|
"""Register the AidotClient coroutine that refreshes the access token."""
|
|
@@ -1371,6 +1389,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1371
1389
|
if self._mqtt_url:
|
|
1372
1390
|
return self._mqtt_url
|
|
1373
1391
|
|
|
1392
|
+
# Test seam: point the whole client at a local broker without any cloud
|
|
1393
|
+
# call (paho derives ws-vs-wss and the port from the scheme, so a plain
|
|
1394
|
+
# "ws://127.0.0.1:PORT/mqtt" needs nothing else). Unset in production.
|
|
1395
|
+
_env_mqtt = os.environ.get("AIDOT_MQTT_URL")
|
|
1396
|
+
if _env_mqtt:
|
|
1397
|
+
_LOGGER.debug("AIDOT_MQTT_URL override in effect: %s", _env_mqtt)
|
|
1398
|
+
self._mqtt_url = _env_mqtt
|
|
1399
|
+
return self._mqtt_url
|
|
1400
|
+
|
|
1374
1401
|
import aiohttp
|
|
1375
1402
|
|
|
1376
1403
|
async def _fetch():
|
|
@@ -1603,11 +1630,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1603
1630
|
|
|
1604
1631
|
@property
|
|
1605
1632
|
def _aidot_v21_base(self) -> str:
|
|
1606
|
-
return f"
|
|
1633
|
+
return f"{aidot_api_base(self._region)}/v21"
|
|
1607
1634
|
|
|
1608
1635
|
@property
|
|
1609
1636
|
def _aidot_v32_base(self) -> str:
|
|
1610
|
-
return f"
|
|
1637
|
+
return f"{aidot_api_base(self._region)}/v32/api/ipc"
|
|
1611
1638
|
|
|
1612
1639
|
def _aidot_headers(self) -> dict:
|
|
1613
1640
|
# Auth headers for the AiDot platform API (prod-{region}-api.arnoo.com).
|
|
@@ -4847,7 +4874,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
4847
4874
|
return None
|
|
4848
4875
|
|
|
4849
4876
|
url = (
|
|
4850
|
-
f"
|
|
4877
|
+
f"{aidot_api_base(self._region)}"
|
|
4851
4878
|
f"/v29/api/webrtc/iceConfig?forceRefresh=0"
|
|
4852
4879
|
)
|
|
4853
4880
|
|
{python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/constants.py
RENAMED
|
@@ -4,6 +4,7 @@ Extracted verbatim from client.py (behavior-preserving). client.py
|
|
|
4
4
|
re-imports these names, so the public/runtime surface is unchanged.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import os
|
|
7
8
|
import struct
|
|
8
9
|
|
|
9
10
|
# AppKey from LDSAppOpenSDK CocoaPods docs (kLDSAppOpenSDKKey = "appa070")
|
|
@@ -17,6 +18,57 @@ _LEEDARSON_APP_ID = "1392315867093508098"
|
|
|
17
18
|
# e.g. "us" -> "https://us-smarthome.arnoo.com:443"
|
|
18
19
|
_SMARTHOME_URL_TEMPLATE = "https://{region}-smarthome.arnoo.com:443"
|
|
19
20
|
|
|
21
|
+
# AiDot platform API base (the prod-{region}-api.arnoo.com family).
|
|
22
|
+
_AIDOT_API_BASE_TEMPLATE = "https://prod-{region}-api.arnoo.com"
|
|
23
|
+
|
|
24
|
+
# --------------------------------------------------------------------------- #
|
|
25
|
+
# Test-environment seams.
|
|
26
|
+
#
|
|
27
|
+
# Every cloud endpoint the camera layer contacts routes through one of these
|
|
28
|
+
# helpers, so an end-to-end test can point the WHOLE client at a local fake
|
|
29
|
+
# environment via env vars (the same opt-in pattern as
|
|
30
|
+
# AIDOT_SDES_HOLEPUNCH_HOST). Read at call time, never cached at import, and
|
|
31
|
+
# defaulting to today's production URLs - unset means byte-identical behavior.
|
|
32
|
+
#
|
|
33
|
+
# These exist so the e2e tier CANNOT reach the real cloud: without them the
|
|
34
|
+
# fake-lab tests fall through to prod-{region}-api.arnoo.com and the live MQTT
|
|
35
|
+
# broker, which is real egress with test credentials from CI.
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def smarthome_base(region: str) -> str:
|
|
39
|
+
"""Smarthome API base for ``region`` (AIDOT_SMARTHOME_URL_TEMPLATE override)."""
|
|
40
|
+
tmpl = os.environ.get("AIDOT_SMARTHOME_URL_TEMPLATE") or _SMARTHOME_URL_TEMPLATE
|
|
41
|
+
return tmpl.format(region=region)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def aidot_api_base(region: str) -> str:
|
|
45
|
+
"""Platform API base for ``region`` (AIDOT_API_BASE_TEMPLATE override)."""
|
|
46
|
+
tmpl = os.environ.get("AIDOT_API_BASE_TEMPLATE") or _AIDOT_API_BASE_TEMPLATE
|
|
47
|
+
return tmpl.format(region=region)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ice_uris_from_env(var: str, default: list) -> list:
|
|
51
|
+
"""Comma-separated ICE URI list from ``var``; unset -> default, "" -> []."""
|
|
52
|
+
env = os.environ.get(var)
|
|
53
|
+
if env is None:
|
|
54
|
+
return list(default)
|
|
55
|
+
return [u.strip() for u in env.split(",") if u.strip()]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def stun_server_uris() -> list:
|
|
59
|
+
"""Default STUN server URIs (AIDOT_STUN_SERVERS override; "" disables)."""
|
|
60
|
+
return _ice_uris_from_env("AIDOT_STUN_SERVERS", ["stun:stun.l.google.com:19302"])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fallback_turn_uris() -> list:
|
|
64
|
+
"""TURN relay URIs appended when the cloud ICE config carries none
|
|
65
|
+
(AIDOT_TURN_SERVERS override; "" disables the hardcoded Arnoo fallback)."""
|
|
66
|
+
return _ice_uris_from_env(
|
|
67
|
+
"AIDOT_TURN_SERVERS",
|
|
68
|
+
["stun:3.230.182.123:3478", "turn:3.230.182.123:5349"],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
20
72
|
# --------------------------------------------------------------------------- #
|
|
21
73
|
# Playback TCP binary framing constants
|
|
22
74
|
#
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Pick the fastest video decoder this host can actually use.
|
|
2
|
+
|
|
3
|
+
Enumeration is not enough. ``ffmpeg -decoders`` lists what the binary was
|
|
4
|
+
COMPILED with, not what works on the machine: a Raspberry Pi 4 happily advertises
|
|
5
|
+
``h264_cuvid`` and ``hevc_cuvid`` with no Nvidia hardware anywhere, and the same
|
|
6
|
+
Pi advertises ``h264_v4l2m2m`` while failing to open it ("No such file or
|
|
7
|
+
directory") even with the kernel device present and the user in the ``video``
|
|
8
|
+
group. Selecting a decoder from that list would not accelerate decoding, it would
|
|
9
|
+
stop decoding working at all.
|
|
10
|
+
|
|
11
|
+
So every candidate is PROVEN before it is offered: a clip at the resolution the
|
|
12
|
+
cameras actually send is generated and decoded with it, and only a clean run
|
|
13
|
+
that produces frames qualifies. The winner is the fastest qualifying candidate,
|
|
14
|
+
which is a hardware path when one genuinely works and software otherwise.
|
|
15
|
+
|
|
16
|
+
Nor is hardware automatically faster. Measured on an Apple M1, VideoToolbox
|
|
17
|
+
decodes H.264 about three times SLOWER than software, in the same pipeline
|
|
18
|
+
production runs. Ranking by measured time rather than by preference is what
|
|
19
|
+
keeps that from becoming a regression sold as an optimisation.
|
|
20
|
+
|
|
21
|
+
Probing costs a second or two per codec, so the verdict is cached on disk. The
|
|
22
|
+
cache key includes the ffmpeg build identity, so upgrading ffmpeg re-probes
|
|
23
|
+
rather than trusting a verdict made about a different binary.
|
|
24
|
+
|
|
25
|
+
Where this matters: the streaming paths use ``-c copy`` and never decode, so they
|
|
26
|
+
are unaffected by design. The decode cost is in the diagnostic/validation path
|
|
27
|
+
(``-f null``), which decodes deliberately - that is what proves a stream is not
|
|
28
|
+
merely arriving but actually decodable, and it is what catches a parameter-set
|
|
29
|
+
mismatch that would otherwise present as a permanently black picture.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import os
|
|
36
|
+
import glob
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
import threading
|
|
40
|
+
import time
|
|
41
|
+
from typing import List, Optional
|
|
42
|
+
|
|
43
|
+
from .protocol import _SPROP_DIR # reuse the same per-user cache root
|
|
44
|
+
|
|
45
|
+
_LOGGER = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
# Ways to decode, best first; the empty list is plain software and always works.
|
|
48
|
+
#
|
|
49
|
+
# There are TWO forms and they are not interchangeable:
|
|
50
|
+
#
|
|
51
|
+
# ["-c:v", "<name>"] a named decoder, e.g. h264_v4l2m2m on a Pi
|
|
52
|
+
# ["-hwaccel", "<method>"] an acceleration method, e.g. videotoolbox on macOS
|
|
53
|
+
#
|
|
54
|
+
# Getting this wrong silently costs the whole feature. VideoToolbox and VAAPI
|
|
55
|
+
# expose no decoder at all - ffmpeg lists h264_videotoolbox and h264_vaapi as
|
|
56
|
+
# ENCODERS - so naming them with -c:v can never work, and every macOS and VAAPI
|
|
57
|
+
# machine would fall back to software while genuinely having hardware decoding
|
|
58
|
+
# available through -hwaccel. Verified on an Apple M1 (videotoolbox appears in
|
|
59
|
+
# -hwaccels, nowhere in -decoders) and on a Pi 4 (same for vaapi).
|
|
60
|
+
#
|
|
61
|
+
# Both forms go before -i. Ordering is only a starting preference; whichever
|
|
62
|
+
# proves fastest wins, and anything that cannot decode is dropped.
|
|
63
|
+
_CANDIDATES = {
|
|
64
|
+
"h264": [
|
|
65
|
+
["-hwaccel", "videotoolbox"], # macOS / Apple silicon
|
|
66
|
+
["-hwaccel", "vaapi"], # generic Linux (Intel/AMD)
|
|
67
|
+
["-c:v", "h264_v4l2m2m"], # Raspberry Pi 4 / many ARM SoCs
|
|
68
|
+
["-c:v", "h264_rkmpp"], # Rockchip
|
|
69
|
+
["-c:v", "h264_qsv"], # Intel QuickSync
|
|
70
|
+
["-c:v", "h264_cuvid"], # Nvidia
|
|
71
|
+
[], # software
|
|
72
|
+
],
|
|
73
|
+
"hevc": [
|
|
74
|
+
["-hwaccel", "videotoolbox"],
|
|
75
|
+
["-hwaccel", "vaapi"],
|
|
76
|
+
["-c:v", "hevc_v4l2m2m"],
|
|
77
|
+
["-c:v", "hevc_rkmpp"],
|
|
78
|
+
["-c:v", "hevc_qsv"],
|
|
79
|
+
["-c:v", "hevc_cuvid"],
|
|
80
|
+
[],
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Bumped when the shape of a cached verdict changes, so an older entry is
|
|
85
|
+
# re-probed instead of being read as something it is not.
|
|
86
|
+
_CACHE_SCHEMA = 2
|
|
87
|
+
|
|
88
|
+
# Force a choice and skip probing. Takes either form: a bare name is a decoder
|
|
89
|
+
# ("h264_v4l2m2m"), a "hwaccel:" prefix an acceleration method
|
|
90
|
+
# ("hwaccel:videotoolbox"). The prefix is needed because the two are not
|
|
91
|
+
# interchangeable - VideoToolbox and VAAPI have no decoder to name.
|
|
92
|
+
_ENV_OVERRIDE = "AIDOT_VIDEO_DECODER"
|
|
93
|
+
_ENV_DISABLE = "AIDOT_DISABLE_HWACCEL" # stay on software decoding
|
|
94
|
+
|
|
95
|
+
_PROBE_TIMEOUT_S = 40.0
|
|
96
|
+
_cache_mem: dict = {}
|
|
97
|
+
_warm_thread: Optional["threading.Thread"] = None
|
|
98
|
+
_warm_lock = threading.Lock()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ffmpeg() -> str:
|
|
102
|
+
return os.environ.get("AIDOT_FFMPEG", "ffmpeg")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_identity_memo: Optional[str] = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _ffmpeg_identity() -> str:
|
|
109
|
+
"""Identify the ffmpeg build, so a verdict never outlives the binary.
|
|
110
|
+
|
|
111
|
+
Derived by STATTING the binary (path, size, mtime) rather than running
|
|
112
|
+
``ffmpeg -version``. Reading a cached verdict has to be nearly free: this is
|
|
113
|
+
consulted on a startup path, and shelling out cost ~3.4s per process on a
|
|
114
|
+
loaded Pi - which is most of a cache hit's budget spent proving the cache is
|
|
115
|
+
still valid. An upgrade changes size or mtime and re-probes, which is the
|
|
116
|
+
property that actually matters.
|
|
117
|
+
"""
|
|
118
|
+
global _identity_memo
|
|
119
|
+
if _identity_memo is not None:
|
|
120
|
+
return _identity_memo
|
|
121
|
+
import shutil
|
|
122
|
+
ident = "unknown"
|
|
123
|
+
try:
|
|
124
|
+
path = shutil.which(_ffmpeg()) or _ffmpeg()
|
|
125
|
+
st = os.stat(path)
|
|
126
|
+
ident = f"{path}:{st.st_size}:{int(st.st_mtime)}"
|
|
127
|
+
except Exception:
|
|
128
|
+
# No resolvable binary: fall back to the version string so a verdict is
|
|
129
|
+
# still keyed to something, at the cost of one subprocess.
|
|
130
|
+
try:
|
|
131
|
+
ident = subprocess.run(
|
|
132
|
+
[_ffmpeg(), "-hide_banner", "-version"],
|
|
133
|
+
capture_output=True, text=True, timeout=10,
|
|
134
|
+
).stdout.splitlines()[0]
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
_identity_memo = hashlib.sha256(ident.encode("utf-8", "replace")).hexdigest()[:16]
|
|
138
|
+
return _identity_memo
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _cache_path() -> str:
|
|
142
|
+
return os.path.join(_SPROP_DIR, "decoders.json")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _load_cache() -> dict:
|
|
146
|
+
try:
|
|
147
|
+
with open(_cache_path()) as fh:
|
|
148
|
+
return json.load(fh)
|
|
149
|
+
except (OSError, ValueError):
|
|
150
|
+
return {}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _save_cache(data: dict) -> None:
|
|
154
|
+
try:
|
|
155
|
+
os.makedirs(_SPROP_DIR, exist_ok=True)
|
|
156
|
+
tmp = _cache_path() + ".tmp"
|
|
157
|
+
with open(tmp, "w") as fh:
|
|
158
|
+
json.dump(data, fh)
|
|
159
|
+
os.replace(tmp, _cache_path())
|
|
160
|
+
except OSError as exc:
|
|
161
|
+
_LOGGER.debug("decoder cache write failed: %s", exc)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_decoders_memo: Optional[set] = None
|
|
165
|
+
_hwaccels_memo: Optional[set] = None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _list_names(flag: str) -> set:
|
|
169
|
+
try:
|
|
170
|
+
out = subprocess.run(
|
|
171
|
+
[_ffmpeg(), "-hide_banner", flag],
|
|
172
|
+
capture_output=True, text=True, timeout=10,
|
|
173
|
+
).stdout
|
|
174
|
+
except Exception:
|
|
175
|
+
return set()
|
|
176
|
+
names = set()
|
|
177
|
+
for ln in out.splitlines():
|
|
178
|
+
parts = ln.split()
|
|
179
|
+
if not parts or ln.startswith(" -") or ln.rstrip().endswith(":"):
|
|
180
|
+
continue
|
|
181
|
+
# -decoders lines are " V....D name description"; -hwaccels is one
|
|
182
|
+
# bare name per line under a heading.
|
|
183
|
+
names.add(parts[1] if flag == "-decoders" and len(parts) > 1 else parts[0])
|
|
184
|
+
return names
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _plausible(cand: List[str]) -> bool:
|
|
188
|
+
"""Could this candidate possibly work, judged without spawning anything?
|
|
189
|
+
|
|
190
|
+
A build advertises support compiled in, not hardware present. Trying a
|
|
191
|
+
candidate costs an ffmpeg process and a couple of seconds on a small board,
|
|
192
|
+
and on a machine with no video hardware at all EVERY trial is wasted - as is
|
|
193
|
+
encoding the sample they would have decoded. Checking for the device first
|
|
194
|
+
costs a stat.
|
|
195
|
+
|
|
196
|
+
Deliberately permissive: it rules out only what is impossible, never what is
|
|
197
|
+
merely unlikely. Anything that survives still has to prove itself.
|
|
198
|
+
"""
|
|
199
|
+
if not cand:
|
|
200
|
+
return True # software
|
|
201
|
+
name = cand[1]
|
|
202
|
+
if name == "videotoolbox":
|
|
203
|
+
return sys.platform == "darwin"
|
|
204
|
+
if name == "vaapi" or name.endswith("_vaapi") or name.endswith("_qsv"):
|
|
205
|
+
return bool(glob.glob("/dev/dri/render*"))
|
|
206
|
+
if name.endswith("_cuvid") or name.endswith("_nvdec"):
|
|
207
|
+
return bool(glob.glob("/dev/nvidia*"))
|
|
208
|
+
if name.endswith("_v4l2m2m"):
|
|
209
|
+
return bool(glob.glob("/dev/video*"))
|
|
210
|
+
if name.endswith("_rkmpp"):
|
|
211
|
+
return bool(glob.glob("/dev/mpp_service") or glob.glob("/dev/rga"))
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _available(cand: List[str]) -> bool:
|
|
216
|
+
"""Cheap pre-filter. Never sufficient on its own - a Pi advertises
|
|
217
|
+
h264_cuvid with no Nvidia hardware present, which is why everything that
|
|
218
|
+
passes here still has to prove it can decode."""
|
|
219
|
+
global _decoders_memo, _hwaccels_memo
|
|
220
|
+
if not cand:
|
|
221
|
+
return True # software
|
|
222
|
+
if cand[0] == "-c:v":
|
|
223
|
+
if _decoders_memo is None:
|
|
224
|
+
_decoders_memo = _list_names("-decoders")
|
|
225
|
+
return cand[1] in _decoders_memo
|
|
226
|
+
if _hwaccels_memo is None:
|
|
227
|
+
_hwaccels_memo = _list_names("-hwaccels")
|
|
228
|
+
return cand[1] in _hwaccels_memo
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _compiled_in(name: str) -> bool:
|
|
232
|
+
"""Back-compat shim: is this named decoder in the build?"""
|
|
233
|
+
return _available(["-c:v", name])
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# The cameras send 1280x720, and the probe clip matches that deliberately.
|
|
237
|
+
# A smaller clip is cheaper but ranks candidates badly: hardware decoding pays a
|
|
238
|
+
# fixed session-setup cost that dominates a tiny job, so a 320x240 clip can make
|
|
239
|
+
# a decoder that wins comfortably on real frames look like a loser. Measured on
|
|
240
|
+
# a Pi 4: 2.75s per candidate at 720p against 1.73s at 320x240 - a fair price
|
|
241
|
+
# for a measurement that reflects the actual workload, and it is paid once per
|
|
242
|
+
# host in the background.
|
|
243
|
+
_SAMPLE = "testsrc=size=1280x720:rate=15:duration=1"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _make_sample(codec: str, path: str) -> bool:
|
|
247
|
+
"""Generate a clip to decode. Encoding is software and that is fine -
|
|
248
|
+
it happens once per probe, not per stream."""
|
|
249
|
+
enc = "libx264" if codec == "h264" else "libx265"
|
|
250
|
+
try:
|
|
251
|
+
r = subprocess.run(
|
|
252
|
+
[_ffmpeg(), "-hide_banner", "-loglevel", "error",
|
|
253
|
+
"-f", "lavfi", "-i", _SAMPLE,
|
|
254
|
+
"-c:v", enc, "-preset", "ultrafast", "-g", "15",
|
|
255
|
+
"-f", codec, "-y", path],
|
|
256
|
+
capture_output=True, text=True, timeout=_PROBE_TIMEOUT_S,
|
|
257
|
+
)
|
|
258
|
+
return r.returncode == 0 and os.path.getsize(path) > 0
|
|
259
|
+
except Exception:
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _try_decoder(cand: List[str], sample: str) -> Optional[float]:
|
|
264
|
+
"""Decode the sample with ``cand``; seconds taken, or None if it cannot.
|
|
265
|
+
|
|
266
|
+
Decodes to rawvideo and requires bytes to come out, not merely a zero exit.
|
|
267
|
+
A decoder that opens, consumes the input and emits nothing would otherwise
|
|
268
|
+
look like a pass - and that is precisely the failure that matters here,
|
|
269
|
+
since it presents downstream as a permanently black picture rather than as
|
|
270
|
+
an error. A non-zero exit or anything on stderr also disqualifies it: a
|
|
271
|
+
decoder that warns its way through is not one to rely on.
|
|
272
|
+
"""
|
|
273
|
+
t0 = time.monotonic()
|
|
274
|
+
try:
|
|
275
|
+
r = subprocess.run(
|
|
276
|
+
[_ffmpeg(), "-hide_banner", "-loglevel", "error",
|
|
277
|
+
*cand, "-i", sample,
|
|
278
|
+
"-f", "rawvideo", "-pix_fmt", "yuv420p", "-"],
|
|
279
|
+
capture_output=True, timeout=_PROBE_TIMEOUT_S,
|
|
280
|
+
)
|
|
281
|
+
except Exception:
|
|
282
|
+
return None
|
|
283
|
+
if r.returncode != 0 or r.stderr.strip() or not r.stdout:
|
|
284
|
+
return None
|
|
285
|
+
return time.monotonic() - t0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def probe_decoder(codec: str, force: bool = False) -> List[str]:
|
|
289
|
+
"""ffmpeg input arguments for the fastest way to decode ``codec`` here.
|
|
290
|
+
|
|
291
|
+
Returns ``["-hwaccel", m]`` or ``["-c:v", name]`` for a hardware path that
|
|
292
|
+
was proven to work on this machine, or ``[]`` for plain software decoding -
|
|
293
|
+
which is also the answer when acceleration is disabled or nothing else
|
|
294
|
+
qualifies. The result always belongs BEFORE ``-i``.
|
|
295
|
+
"""
|
|
296
|
+
codec = codec.lower()
|
|
297
|
+
if codec not in _CANDIDATES:
|
|
298
|
+
return []
|
|
299
|
+
|
|
300
|
+
forced = os.environ.get(_ENV_OVERRIDE, "").strip()
|
|
301
|
+
if forced:
|
|
302
|
+
# Accept either form: a bare name is a decoder, "hwaccel:x" a method.
|
|
303
|
+
if forced.startswith("hwaccel:"):
|
|
304
|
+
return ["-hwaccel", forced.split(":", 1)[1]]
|
|
305
|
+
return ["-c:v", forced]
|
|
306
|
+
if os.environ.get(_ENV_DISABLE, "").strip().lower() in ("1", "true", "yes", "on"):
|
|
307
|
+
return []
|
|
308
|
+
|
|
309
|
+
key = f"v{_CACHE_SCHEMA}:{codec}:{_ffmpeg_identity()}"
|
|
310
|
+
if not force and key in _cache_mem:
|
|
311
|
+
return list(_cache_mem[key])
|
|
312
|
+
|
|
313
|
+
cache = _load_cache()
|
|
314
|
+
if not force and isinstance(cache.get(key), list):
|
|
315
|
+
_cache_mem[key] = list(cache[key])
|
|
316
|
+
return list(cache[key])
|
|
317
|
+
|
|
318
|
+
# Everything below spawns processes, so decide first whether it is worth
|
|
319
|
+
# spawning any. A machine with no video hardware - a VPS, a container, most
|
|
320
|
+
# cloud installs - can be answered here for free, and encoding a sample it
|
|
321
|
+
# would never decode is the single largest cost in this module.
|
|
322
|
+
hw = [c for c in _CANDIDATES[codec] if c and _plausible(c) and _available(c)]
|
|
323
|
+
if not hw:
|
|
324
|
+
_LOGGER.debug("decoder probe: no hardware candidate is possible for %s "
|
|
325
|
+
"on this machine; software, nothing probed", codec)
|
|
326
|
+
cache[key] = []
|
|
327
|
+
_cache_mem[key] = []
|
|
328
|
+
_save_cache(cache)
|
|
329
|
+
return []
|
|
330
|
+
|
|
331
|
+
import tempfile
|
|
332
|
+
winner: List[str] = []
|
|
333
|
+
best = None
|
|
334
|
+
with tempfile.TemporaryDirectory() as td:
|
|
335
|
+
sample = os.path.join(td, f"probe.{codec}")
|
|
336
|
+
if not _make_sample(codec, sample):
|
|
337
|
+
_LOGGER.debug("decoder probe: could not build a %s sample", codec)
|
|
338
|
+
return []
|
|
339
|
+
for cand in hw:
|
|
340
|
+
took = _try_decoder(cand, sample)
|
|
341
|
+
if took is None:
|
|
342
|
+
_LOGGER.debug("decoder probe: %s cannot decode %s here",
|
|
343
|
+
" ".join(cand), codec)
|
|
344
|
+
continue
|
|
345
|
+
if best is None or took < best:
|
|
346
|
+
winner, best = list(cand), took
|
|
347
|
+
# Software is only worth timing if something beat it to the line. When
|
|
348
|
+
# every hardware candidate failed - the common case on small boards -
|
|
349
|
+
# software wins by default and the measurement would change nothing.
|
|
350
|
+
if winner:
|
|
351
|
+
sw = _try_decoder([], sample)
|
|
352
|
+
if sw is not None and sw <= (best or 0.0):
|
|
353
|
+
_LOGGER.debug("decoder probe: software (%.2fs) beats %s (%.2fs) "
|
|
354
|
+
"for %s", sw, " ".join(winner), best or 0.0, codec)
|
|
355
|
+
winner, best = [], sw
|
|
356
|
+
|
|
357
|
+
if winner:
|
|
358
|
+
_LOGGER.info("decoder probe: using %s for %s (%.2fs on the probe clip)",
|
|
359
|
+
" ".join(winner), codec, best or 0.0)
|
|
360
|
+
else:
|
|
361
|
+
_LOGGER.debug("decoder probe: software decoding for %s", codec)
|
|
362
|
+
cache[key] = winner
|
|
363
|
+
_cache_mem[key] = list(winner)
|
|
364
|
+
_save_cache(cache)
|
|
365
|
+
return list(winner)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def warm_decoder_cache(codecs: tuple = ("h264",)) -> "threading.Thread":
|
|
369
|
+
"""Probe in the background so the first real use is already cached.
|
|
370
|
+
|
|
371
|
+
Probing costs about ten seconds per codec the first time on a small ARM
|
|
372
|
+
board, which is far too long to block a startup path - but it only ever
|
|
373
|
+
happens once per host per ffmpeg build, and the answer is wanted before the
|
|
374
|
+
first stream rather than during it. So run it on a daemon thread and let the
|
|
375
|
+
caller carry on: anything asking for a decoder meanwhile simply computes it
|
|
376
|
+
inline, and the result is shared through the same cache either way.
|
|
377
|
+
|
|
378
|
+
Only H.264 is warmed by default, because only H.264 is ingested: probing
|
|
379
|
+
H.265 as well doubled the cost of this module on a Pi 4 (22s of 47s wall,
|
|
380
|
+
15s of 26s CPU) to answer a question nothing asks. If H.265 ingest lands,
|
|
381
|
+
add it here - and until then a stray H.265 stream still works, it just lets
|
|
382
|
+
ffmpeg choose on the first session while the answer is worked out.
|
|
383
|
+
|
|
384
|
+
Idempotent: a second call while the first is still running is a no-op, so
|
|
385
|
+
every config entry can call it at setup without spawning a probe each.
|
|
386
|
+
|
|
387
|
+
Returns the thread so a caller that wants to can join it; ignoring it is the
|
|
388
|
+
expected use. Returns the in-flight thread if one is already running.
|
|
389
|
+
"""
|
|
390
|
+
global _warm_thread
|
|
391
|
+
with _warm_lock:
|
|
392
|
+
if _warm_thread is not None and _warm_thread.is_alive():
|
|
393
|
+
return _warm_thread
|
|
394
|
+
|
|
395
|
+
def _run() -> None:
|
|
396
|
+
for codec in codecs:
|
|
397
|
+
try:
|
|
398
|
+
probe_decoder(codec)
|
|
399
|
+
except Exception:
|
|
400
|
+
_LOGGER.debug("decoder probe failed for %s", codec, exc_info=True)
|
|
401
|
+
|
|
402
|
+
with _warm_lock:
|
|
403
|
+
if _warm_thread is not None and _warm_thread.is_alive():
|
|
404
|
+
return _warm_thread
|
|
405
|
+
_warm_thread = threading.Thread(
|
|
406
|
+
target=_run, name="aidot-decoder-probe", daemon=True)
|
|
407
|
+
_warm_thread.start()
|
|
408
|
+
return _warm_thread
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def cached_decoder(codec: str) -> Optional[List[str]]:
|
|
412
|
+
"""The cached verdict for ``codec``, or None if it has not been probed yet.
|
|
413
|
+
|
|
414
|
+
Never probes, so it is safe to call from an event loop - probing shells out
|
|
415
|
+
for several seconds on a first run and would stall every other camera. A
|
|
416
|
+
caller that gets None should simply let ffmpeg choose, which is the
|
|
417
|
+
behaviour that existed before any of this; ``warm_decoder_cache()`` fills
|
|
418
|
+
the cache in the background so the answer is normally already there.
|
|
419
|
+
|
|
420
|
+
An empty list is a real answer meaning "software decoding", and is
|
|
421
|
+
deliberately distinct from None.
|
|
422
|
+
"""
|
|
423
|
+
codec = codec.lower()
|
|
424
|
+
if codec not in _CANDIDATES:
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
forced = os.environ.get(_ENV_OVERRIDE, "").strip()
|
|
428
|
+
if forced:
|
|
429
|
+
if forced.startswith("hwaccel:"):
|
|
430
|
+
return ["-hwaccel", forced.split(":", 1)[1]]
|
|
431
|
+
return ["-c:v", forced]
|
|
432
|
+
if os.environ.get(_ENV_DISABLE, "").strip().lower() in ("1", "true", "yes", "on"):
|
|
433
|
+
return []
|
|
434
|
+
|
|
435
|
+
key = f"v{_CACHE_SCHEMA}:{codec}:{_ffmpeg_identity()}"
|
|
436
|
+
if key in _cache_mem:
|
|
437
|
+
return list(_cache_mem[key])
|
|
438
|
+
val = _load_cache().get(key)
|
|
439
|
+
if isinstance(val, list):
|
|
440
|
+
_cache_mem[key] = list(val)
|
|
441
|
+
return list(val)
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def decoder_args(codec: str) -> List[str]:
|
|
446
|
+
"""Input arguments for decoding ``codec``, probing if necessary."""
|
|
447
|
+
return probe_decoder(codec)
|
{python_aidot_cameras-0.14.0 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/protocol.py
RENAMED
|
@@ -583,6 +583,26 @@ def _sprop_cache_path(devid: str) -> str:
|
|
|
583
583
|
return os.path.join(_SPROP_DIR, f"{safe}.sprop")
|
|
584
584
|
|
|
585
585
|
|
|
586
|
+
def _sprop_unstable_path(devid: str) -> str:
|
|
587
|
+
"""Marker file: this camera does NOT keep its parameter sets stable."""
|
|
588
|
+
return _sprop_cache_path(devid) + ".unstable"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _sprop_is_unstable(devid: str) -> bool:
|
|
592
|
+
"""True once this camera has been seen to change its parameter sets.
|
|
593
|
+
|
|
594
|
+
Injecting a cached ``sprop-parameter-sets`` is only safe while the camera
|
|
595
|
+
keeps sending the SAME sets. An A001064 was measured changing its
|
|
596
|
+
``seq_parameter_set_id`` between sessions (cache held id 0, the stream then
|
|
597
|
+
referenced id 3), and out-of-band parameter sets that name the wrong id are
|
|
598
|
+
far worse than none: ffmpeg initialises its decoder from them, every slice
|
|
599
|
+
then references an id the decoder does not have, and the whole session
|
|
600
|
+
decodes nothing - "sps_id 3 out of range", "non-existing PPS 0 referenced",
|
|
601
|
+
"no frame!". A MISSING set only costs the wait for the next in-band IDR.
|
|
602
|
+
"""
|
|
603
|
+
return os.path.exists(_sprop_unstable_path(devid))
|
|
604
|
+
|
|
605
|
+
|
|
586
606
|
def _load_sprop(devid: str) -> "Optional[str]":
|
|
587
607
|
"""Cached ``sprop-parameter-sets`` for ``devid`` (captured from a prior
|
|
588
608
|
stream), or None. Fail-safe: any error -> None (SDP omits sprop = today's
|
|
@@ -596,8 +616,33 @@ def _load_sprop(devid: str) -> "Optional[str]":
|
|
|
596
616
|
|
|
597
617
|
|
|
598
618
|
def _save_sprop(devid: str, sprop: str) -> None:
|
|
599
|
-
"""Persist the ``sprop-parameter-sets`` string for ``devid`` (best-effort).
|
|
619
|
+
"""Persist the ``sprop-parameter-sets`` string for ``devid`` (best-effort).
|
|
620
|
+
|
|
621
|
+
If a DIFFERENT value was already cached, this camera does not keep its
|
|
622
|
+
parameter sets stable: mark it and drop the cache, so nothing is injected
|
|
623
|
+
for it again (see _sprop_is_unstable). The caller only reaches here from
|
|
624
|
+
in-band capture, so such a camera demonstrably sends its sets in-band and
|
|
625
|
+
loses nothing by the injection being skipped.
|
|
626
|
+
"""
|
|
627
|
+
if _sprop_is_unstable(devid):
|
|
628
|
+
return # already known unstable; nothing to cache for this camera
|
|
600
629
|
try:
|
|
630
|
+
prev = _load_sprop(devid)
|
|
631
|
+
if prev is not None and prev != sprop:
|
|
632
|
+
_LOGGER.warning(
|
|
633
|
+
"camera %s: in-band parameter sets differ from the cached ones - "
|
|
634
|
+
"this camera changes them between sessions, so out-of-band "
|
|
635
|
+
"injection is now disabled for it (a stale set decodes nothing)",
|
|
636
|
+
devid,
|
|
637
|
+
)
|
|
638
|
+
try:
|
|
639
|
+
os.makedirs(_SPROP_DIR, exist_ok=True)
|
|
640
|
+
with open(_sprop_unstable_path(devid), "w") as fh:
|
|
641
|
+
fh.write("parameter sets changed between sessions\n")
|
|
642
|
+
os.remove(_sprop_cache_path(devid))
|
|
643
|
+
except OSError:
|
|
644
|
+
pass
|
|
645
|
+
return
|
|
601
646
|
os.makedirs(_SPROP_DIR, exist_ok=True)
|
|
602
647
|
tmp = _sprop_cache_path(devid) + ".tmp"
|
|
603
648
|
with open(tmp, "w") as fh:
|
|
@@ -616,6 +661,8 @@ def _inject_sprop(sdp: str, devid: str) -> str:
|
|
|
616
661
|
in-band SPS loss). No-op if nothing is cached yet or sprop is already
|
|
617
662
|
present. Applied at every ffmpeg-input SDP write so both the serve path and
|
|
618
663
|
the snapshot/file path benefit."""
|
|
664
|
+
if _sprop_is_unstable(devid):
|
|
665
|
+
return sdp
|
|
619
666
|
sprop = _load_sprop(devid)
|
|
620
667
|
if not sprop or "sprop-parameter-sets=" in sdp:
|
|
621
668
|
return sdp
|