python-aidot-cameras 0.12.13__tar.gz → 0.12.15__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.12.13/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.15}/PKG-INFO +1 -1
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/client.py +33 -6
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/protocol.py +75 -7
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/sdes_open.py +57 -32
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/webrtc_open.py +12 -8
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/pyproject.toml +1 -1
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- python_aidot_cameras-0.12.15/tests/test_battery_skip_turn.py +59 -0
- python_aidot_cameras-0.12.15/tests/test_dtls_mux_terminal_write.py +126 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_live_stream_param.py +48 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdp_payload_type_narrowing.py +68 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/LICENSE +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/README.md +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/constants.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/sdes.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/client.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/device_client.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/setup.cfg +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dormant_camera_cleanup.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_wait_or_event.py +0 -0
{python_aidot_cameras-0.12.13/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.15}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.15
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
{python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/client.py
RENAMED
|
@@ -369,6 +369,7 @@ def _build_sdes_serve_cmd(
|
|
|
369
369
|
max_seconds: Optional[float] = None,
|
|
370
370
|
sdes_audio: bool = False,
|
|
371
371
|
audio_gain_db: float = -8.0,
|
|
372
|
+
push_video_only: bool = False,
|
|
372
373
|
) -> list:
|
|
373
374
|
"""Build the ffmpeg argv for the SDES bridge serve.
|
|
374
375
|
|
|
@@ -425,8 +426,13 @@ def _build_sdes_serve_cmd(
|
|
|
425
426
|
rtsp_push_url,
|
|
426
427
|
]
|
|
427
428
|
elif rtsp_push_url:
|
|
429
|
+
# PUSH is G.711 passthrough (-c copy) as always - EXCEPT when the audio
|
|
430
|
+
# payload type was never observed (push_video_only): announcing an
|
|
431
|
+
# un-narrowed audio line the server cannot accept kills the whole
|
|
432
|
+
# publish with 400 Bad Request, so map video only and announce clean.
|
|
428
433
|
dest_args = [
|
|
429
|
-
"-c", "copy"
|
|
434
|
+
*(["-map", "0:v:0", "-c:v", "copy"] if push_video_only
|
|
435
|
+
else ["-c", "copy"]),
|
|
430
436
|
*time_args,
|
|
431
437
|
"-f", "rtsp", "-rtsp_transport", "tcp",
|
|
432
438
|
rtsp_push_url,
|
|
@@ -2695,9 +2701,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
2695
2701
|
from a Home Assistant config-entry option, since HA OS can't set env vars).
|
|
2696
2702
|
|
|
2697
2703
|
``live_stream_param`` toggles the battery-camera cloud pre-connect
|
|
2698
|
-
(liveStreamParam)
|
|
2699
|
-
|
|
2700
|
-
|
|
2704
|
+
(liveStreamParam), which is **off by default**: it provisions the camera
|
|
2705
|
+
toward AWS KVS and diverts an A001513's media away from the SDES bridge
|
|
2706
|
+
(no video RTP arrives). Like ``fast_connect`` it overrides the
|
|
2707
|
+
``AIDOT_LIVESTREAM_PARAM`` env var; set True only to re-enable the
|
|
2708
|
+
pre-connect for a camera that needs it.
|
|
2701
2709
|
|
|
2702
2710
|
``serve_relay`` toggles the cold-start serve-port relay (holds the public
|
|
2703
2711
|
serve port connectable through the WebRTC handshake so an eager go2rtc
|
|
@@ -3571,7 +3579,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3571
3579
|
|
|
3572
3580
|
Per-camera ``sdes_skip_turn`` (set via start_keepalive) wins; else the
|
|
3573
3581
|
``AIDOT_SDES_SKIP_TURN_PREALLOC`` env (truthy = 1/true/yes/on), default
|
|
3574
|
-
off. Only consulted for SDES cameras.
|
|
3582
|
+
off. Only consulted for SDES cameras.
|
|
3583
|
+
|
|
3584
|
+
NEVER skipped for a battery camera, whatever the opt/env say: a battery
|
|
3585
|
+
camera sleeps and is woken through the cloud, so it is reached over the
|
|
3586
|
+
TURN relay rather than a host-direct LAN path. Skipping the relay
|
|
3587
|
+
pre-allocation there means the camera has no reachable path back and
|
|
3588
|
+
sends no media at all (validated live on an A001513: with the relay it
|
|
3589
|
+
streams h264 1280x960, with skip_turn it serves nothing). The LAN-direct
|
|
3590
|
+
latency win only applies to a mains camera that actually has a host
|
|
3591
|
+
candidate on the HA segment."""
|
|
3592
|
+
if getattr(self, "is_battery_camera", False):
|
|
3593
|
+
return False
|
|
3575
3594
|
opt = getattr(self, "_sdes_skip_turn_opt", None)
|
|
3576
3595
|
if opt is not None:
|
|
3577
3596
|
return bool(opt)
|
|
@@ -4080,7 +4099,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
4080
4099
|
# Unknown: keep the old staleness heuristic rather
|
|
4081
4100
|
# than holding the stream open forever.
|
|
4082
4101
|
idle_release = True
|
|
4083
|
-
|
|
4102
|
+
# Only an actual idle release ends this serve cycle.
|
|
4103
|
+
# (This break was unconditional - any idle_secs > 0
|
|
4104
|
+
# tore down a HEALTHY ffmpeg after one 0.5s tick,
|
|
4105
|
+
# respawning it forever: ffmpeg died at output-open
|
|
4106
|
+
# with "Immediate exit requested" and every DTLS
|
|
4107
|
+
# camera served nothing. stream_idle_s=0 masked it,
|
|
4108
|
+
# which is why never-release fleets still worked.)
|
|
4109
|
+
if idle_release:
|
|
4110
|
+
break
|
|
4084
4111
|
# Tear down this ffmpeg+mux cycle before the next.
|
|
4085
4112
|
stop_flag.set()
|
|
4086
4113
|
try:
|
{python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/protocol.py
RENAMED
|
@@ -925,8 +925,52 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
|
|
|
925
925
|
except Exception as exc: # pragma: no cover
|
|
926
926
|
_LOGGER.warning("DTLS A/V mux: PyAV unavailable: %s", exc)
|
|
927
927
|
return
|
|
928
|
+
|
|
929
|
+
class _FailFlagWriter:
|
|
930
|
+
"""Write-only stand-in for the pipe that RECORDS write failures.
|
|
931
|
+
|
|
932
|
+
FFmpeg's mpegts muxer checks avio write errors lazily: a dead pipe
|
|
933
|
+
(EPIPE after its ffmpeg exits) makes ``out.mux()`` appear to succeed
|
|
934
|
+
forever, and the error only surfaces - or hangs - at trailer time.
|
|
935
|
+
PyAV's pyio callback DOES see each failed ``write``; recording it here
|
|
936
|
+
lets the mux loop notice within one iteration and exit, instead of an
|
|
937
|
+
abandoned thread draining the SHARED vq/aq into a dead pipe and
|
|
938
|
+
starving the next cycle's ffmpeg (the 2026-07 dead-DTLS-video field
|
|
939
|
+
failure). Deliberately exposes no seek/tell: the pipe is streaming.
|
|
940
|
+
"""
|
|
941
|
+
|
|
942
|
+
def __init__(self, f):
|
|
943
|
+
self._f = f
|
|
944
|
+
self.failed = False
|
|
945
|
+
|
|
946
|
+
def write(self, b):
|
|
947
|
+
try:
|
|
948
|
+
return self._f.write(b)
|
|
949
|
+
except Exception:
|
|
950
|
+
self.failed = True
|
|
951
|
+
raise
|
|
952
|
+
|
|
953
|
+
def flush(self):
|
|
954
|
+
try:
|
|
955
|
+
self._f.flush()
|
|
956
|
+
except Exception:
|
|
957
|
+
self.failed = True
|
|
958
|
+
raise
|
|
959
|
+
|
|
960
|
+
def close(self):
|
|
961
|
+
self._f.close()
|
|
962
|
+
|
|
963
|
+
wsink = _FailFlagWriter(out_fileobj)
|
|
928
964
|
try:
|
|
929
|
-
|
|
965
|
+
# max_interleave_delta (default 10s of stream time): with an AAC stream
|
|
966
|
+
# declared but no audio packets arriving, libavformat's interleave
|
|
967
|
+
# queue holds EVERY video packet waiting to order them against audio -
|
|
968
|
+
# zero bytes reach the pipe, the serve ffmpeg starves at its input
|
|
969
|
+
# probe and never binds its -listen port (field failure 2026-07: all
|
|
970
|
+
# DTLS cameras dark while the taps were full of frames). 100ms keeps
|
|
971
|
+
# video flowing whether or not audio ever shows up.
|
|
972
|
+
out = av.open(wsink, "w", format="mpegts",
|
|
973
|
+
options={"max_interleave_delta": "100000"})
|
|
930
974
|
except Exception as exc:
|
|
931
975
|
_LOGGER.warning("DTLS A/V mux: av.open failed: %s", exc)
|
|
932
976
|
return
|
|
@@ -975,6 +1019,14 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
|
|
|
975
1019
|
a_rtp0 = [None] # first audio RTP timestamp (8 kHz units), for gap detection
|
|
976
1020
|
a_in = [0] # 8 kHz samples emitted to the resampler so far (incl. concealed)
|
|
977
1021
|
vstarted = [False]
|
|
1022
|
+
# A failed container write is TERMINAL for this mux: the pipe's ffmpeg is
|
|
1023
|
+
# gone (EPIPE) and can never come back for this cycle. Swallowing it and
|
|
1024
|
+
# looping used to leave abandoned mux threads (the teardown join(2.0) gives
|
|
1025
|
+
# up on a thread blocked in write) racing the NEXT cycle's thread for the
|
|
1026
|
+
# SHARED vq/aq - stealing its frames into a dead pipe, starving the new
|
|
1027
|
+
# ffmpeg at its input probe so it never bound its -listen port. One dead
|
|
1028
|
+
# write -> exit the thread and leave the queues alone.
|
|
1029
|
+
mux_dead = [False]
|
|
978
1030
|
|
|
979
1031
|
def _flush_video():
|
|
980
1032
|
while True:
|
|
@@ -995,7 +1047,9 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
|
|
|
995
1047
|
out.mux(pkt)
|
|
996
1048
|
progress[0] = _t.monotonic()
|
|
997
1049
|
except Exception:
|
|
998
|
-
|
|
1050
|
+
mux_dead[0] = True
|
|
1051
|
+
_LOGGER.debug("DTLS A/V mux: video write failed - ending mux thread", exc_info=True)
|
|
1052
|
+
return
|
|
999
1053
|
|
|
1000
1054
|
def _flush_audio(drain=False):
|
|
1001
1055
|
if not have_audio:
|
|
@@ -1115,20 +1169,34 @@ def _dtls_av_mux_run(vq, aq, out_fileobj, progress, stop_flag) -> None:
|
|
|
1115
1169
|
out.mux(opkt)
|
|
1116
1170
|
progress[0] = _t.monotonic()
|
|
1117
1171
|
except Exception:
|
|
1118
|
-
|
|
1172
|
+
mux_dead[0] = True
|
|
1173
|
+
_LOGGER.debug("DTLS A/V mux: audio write failed - ending mux thread", exc_info=True)
|
|
1174
|
+
return
|
|
1175
|
+
|
|
1176
|
+
def _dead() -> bool:
|
|
1177
|
+
return mux_dead[0] or wsink.failed
|
|
1119
1178
|
|
|
1120
|
-
while not stop_flag.is_set():
|
|
1179
|
+
while not stop_flag.is_set() and not _dead():
|
|
1121
1180
|
_flush_video()
|
|
1122
1181
|
_flush_audio()
|
|
1123
1182
|
_t.sleep(0.005) # 5 ms - processes audio within 5 ms of arrival vs 20 ms
|
|
1124
|
-
|
|
1125
|
-
|
|
1183
|
+
if not _dead():
|
|
1184
|
+
_flush_video()
|
|
1185
|
+
_flush_audio(drain=True) # final: pad the last partial frame so it flushes
|
|
1126
1186
|
try:
|
|
1127
|
-
if have_audio:
|
|
1187
|
+
if have_audio and not _dead():
|
|
1128
1188
|
for opkt in aenc.encode(None): # flush
|
|
1129
1189
|
out.mux(opkt)
|
|
1130
1190
|
except Exception:
|
|
1131
1191
|
_LOGGER.debug("swallowed exception in %s", '_dtls_av_mux_run', exc_info=True)
|
|
1192
|
+
if _dead():
|
|
1193
|
+
# Close the raw pipe FIRST: the container's trailer flush then fails
|
|
1194
|
+
# fast ("closed file") instead of hanging on the dead pipe.
|
|
1195
|
+
_LOGGER.debug("DTLS A/V mux: sink write failed - ending mux thread")
|
|
1196
|
+
try:
|
|
1197
|
+
wsink.close()
|
|
1198
|
+
except Exception:
|
|
1199
|
+
pass
|
|
1132
1200
|
try:
|
|
1133
1201
|
out.close()
|
|
1134
1202
|
except Exception:
|
{python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/sdes_open.py
RENAMED
|
@@ -36,7 +36,9 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
36
36
|
|
|
37
37
|
# Payload types the ffmpeg SDP advertises per media line, and their alternates.
|
|
38
38
|
# The SDP offers both because the camera picks one per session.
|
|
39
|
-
|
|
39
|
+
# Video payloads the cameras negotiate: 96=H.264, 97/98=H.265 (two variants).
|
|
40
|
+
_SDP_VIDEO_PTS = (96, 97, 98)
|
|
41
|
+
_SDP_AUDIO_PTS = (0, 8)
|
|
40
42
|
|
|
41
43
|
# How long to wait for the FIRST media of a session before launching the serve.
|
|
42
44
|
# Nothing useful can happen before then: the payload types are unknown, so the SDP
|
|
@@ -70,16 +72,33 @@ def narrow_sdp_payload_types(sdp_text: str, keep_video=None, keep_audio=None) ->
|
|
|
70
72
|
included - while every other signal looks healthy.
|
|
71
73
|
|
|
72
74
|
``keep_video`` / ``keep_audio`` of None leaves that line alone.
|
|
75
|
+
|
|
76
|
+
Handles any payload list on the m-line (``96 97``, ``96 97 98``, ...): the
|
|
77
|
+
kept line advertises ONLY the kept payload and the rtpmap/fmtp lines of
|
|
78
|
+
every dropped payload are removed. The old implementation replaced the
|
|
79
|
+
literal ``" 96 97"``, so the day the SDP template grew a third video
|
|
80
|
+
payload (98, the second H.265 variant) narrowing silently stopped working
|
|
81
|
+
for it - and an un-narrowed multi-codec SDP makes the RTSP-push ANNOUNCE
|
|
82
|
+
carry a parameterless H.265 stream that go2rtc rejects with 400.
|
|
73
83
|
"""
|
|
74
|
-
drop =
|
|
75
|
-
if k in _SDP_ALTERNATE_PT}
|
|
84
|
+
drop: set = set()
|
|
76
85
|
kept = []
|
|
77
86
|
for line in sdp_text.splitlines(keepends=True):
|
|
78
87
|
stripped = line.lstrip()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
m_kind = keep = None
|
|
89
|
+
if stripped.startswith("m=video"):
|
|
90
|
+
m_kind, keep = "video", keep_video
|
|
91
|
+
elif stripped.startswith("m=audio"):
|
|
92
|
+
m_kind, keep = "audio", keep_audio
|
|
93
|
+
if m_kind is not None and keep is not None:
|
|
94
|
+
head, _, pts = stripped.partition(" RTP/")
|
|
95
|
+
proto, _, pt_list = pts.partition(" ")
|
|
96
|
+
listed = pt_list.split()
|
|
97
|
+
if str(keep) in listed:
|
|
98
|
+
drop.update(p for p in listed if p != str(keep))
|
|
99
|
+
line = (line[: len(line) - len(stripped)]
|
|
100
|
+
+ f"{head} RTP/{proto} {keep}"
|
|
101
|
+
+ ("\r\n" if line.endswith("\r\n") else "\n"))
|
|
83
102
|
elif any(
|
|
84
103
|
stripped.startswith(f"a={attr}:{pt}{sep}")
|
|
85
104
|
for pt in drop
|
|
@@ -3537,16 +3556,11 @@ class _SdesOpenMixin:
|
|
|
3537
3556
|
out_dir = os.path.dirname(output_path)
|
|
3538
3557
|
if out_dir:
|
|
3539
3558
|
os.makedirs(out_dir, exist_ok=True)
|
|
3540
|
-
#
|
|
3559
|
+
# The ffmpeg command is built once below, AFTER the first-media wait
|
|
3560
|
+
# resolves the real payload types (single source of truth:
|
|
3561
|
+
# _build_sdes_serve_cmd) - so both the SDP narrowing and the push
|
|
3562
|
+
# video-only decision see the observed codecs.
|
|
3541
3563
|
_serve_audio = self._resolve_sdes_serve_audio()
|
|
3542
|
-
cmd = _build_sdes_serve_cmd(
|
|
3543
|
-
sdp_path=sdp_path,
|
|
3544
|
-
rtsp_push_url=rtsp_push_url,
|
|
3545
|
-
output_path=output_path,
|
|
3546
|
-
max_seconds=max_seconds,
|
|
3547
|
-
sdes_audio=_serve_audio,
|
|
3548
|
-
audio_gain_db=self._resolve_sdes_audio_gain_db(),
|
|
3549
|
-
)
|
|
3550
3564
|
# --- H.265 fix: narrow the ffmpeg SDP to the camera's actual codec ----
|
|
3551
3565
|
# The camera streams H.264 (pt=96) OR H.265 (pt=97), varying per session.
|
|
3552
3566
|
# An m=video line listing both ("96 97") makes ffmpeg bind the RTP
|
|
@@ -3578,10 +3592,15 @@ class _SdesOpenMixin:
|
|
|
3578
3592
|
await asyncio.sleep(0.1)
|
|
3579
3593
|
_vpt = _first_video_pt[0]
|
|
3580
3594
|
_apt = _first_audio_pt[0]
|
|
3581
|
-
if
|
|
3595
|
+
# Which single payload type (if any) each line can be narrowed to. Audio
|
|
3596
|
+
# is usable only when its type was actually observed; otherwise the "0 8"
|
|
3597
|
+
# line cannot be narrowed.
|
|
3598
|
+
_keep_v = int(_vpt) if _vpt in _SDP_VIDEO_PTS else None
|
|
3599
|
+
_keep_a = int(_apt) if _apt in _SDP_AUDIO_PTS else None
|
|
3600
|
+
if _serve_audio and _keep_a is None:
|
|
3582
3601
|
# No audio observed in the window, so the line cannot be narrowed and
|
|
3583
|
-
# mapping audio would stall the mux
|
|
3584
|
-
# serving nothing, and say so.
|
|
3602
|
+
# mapping audio would stall the mux (pull) or break the ANNOUNCE
|
|
3603
|
+
# (push). Serve video only rather than serving nothing, and say so.
|
|
3585
3604
|
_LOGGER.warning(
|
|
3586
3605
|
"camera %s: no audio observed before the serve launched, so the "
|
|
3587
3606
|
"audio payload type is unknown; mapping it would stall the mpegts "
|
|
@@ -3589,14 +3608,22 @@ class _SdesOpenMixin:
|
|
|
3589
3608
|
getattr(self, "device_id", "?"),
|
|
3590
3609
|
)
|
|
3591
3610
|
_serve_audio = False
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3611
|
+
# RTSP-PUSH copies EVERY input stream (-c copy). If the audio line was
|
|
3612
|
+
# never narrowed to a single type, its multi-PT ANNOUNCE is rejected by
|
|
3613
|
+
# the RTSP server (400 Bad Request) and takes the whole publish down - so
|
|
3614
|
+
# push must map video only whenever audio is not usable. (Pull already
|
|
3615
|
+
# gates audio via sdes_audio in _build_sdes_serve_cmd.)
|
|
3616
|
+
_is_push = bool(rtsp_push_url) and not rtsp_push_url.startswith("http")
|
|
3617
|
+
_push_video_only = _is_push and _keep_a is None
|
|
3618
|
+
cmd = _build_sdes_serve_cmd(
|
|
3619
|
+
sdp_path=sdp_path,
|
|
3620
|
+
rtsp_push_url=rtsp_push_url,
|
|
3621
|
+
output_path=output_path,
|
|
3622
|
+
max_seconds=max_seconds,
|
|
3623
|
+
sdes_audio=_serve_audio,
|
|
3624
|
+
audio_gain_db=self._resolve_sdes_audio_gain_db(),
|
|
3625
|
+
push_video_only=_push_video_only,
|
|
3626
|
+
)
|
|
3600
3627
|
# Audio matters even more than video here. A multi-PT m-line makes ffmpeg
|
|
3601
3628
|
# bind the depacketizer to the FIRST payload type and silently discard the
|
|
3602
3629
|
# rest, and the mpegts mux withholds its PAT/PMT until EVERY mapped stream
|
|
@@ -3604,13 +3631,11 @@ class _SdesOpenMixin:
|
|
|
3604
3631
|
# on a camera that sends PCMA discards every audio packet and the consumer
|
|
3605
3632
|
# receives zero bytes. That takes the video down with it, which is why
|
|
3606
3633
|
# enabling serve audio appeared to break streaming outright.
|
|
3607
|
-
if
|
|
3634
|
+
if _keep_v is not None or _keep_a is not None:
|
|
3608
3635
|
def _read_sdp_file() -> str:
|
|
3609
3636
|
with open(sdp_path, encoding="utf-8") as _f_sdp:
|
|
3610
3637
|
return _f_sdp.read()
|
|
3611
3638
|
try:
|
|
3612
|
-
_keep_v = int(_vpt) if _vpt in (96, 97) else None
|
|
3613
|
-
_keep_a = int(_apt) if (_serve_audio and _apt in (0, 8)) else None
|
|
3614
3639
|
_cur_sdp = await asyncio.get_running_loop().run_in_executor(
|
|
3615
3640
|
None, _read_sdp_file)
|
|
3616
3641
|
await asyncio.get_running_loop().run_in_executor(
|
|
@@ -3619,7 +3644,7 @@ class _SdesOpenMixin:
|
|
|
3619
3644
|
_status(
|
|
3620
3645
|
"SDES: narrowed ffmpeg SDP to"
|
|
3621
3646
|
+ (f" video pt={_keep_v}"
|
|
3622
|
-
f" ({'
|
|
3647
|
+
f" ({'H264' if _keep_v == 96 else 'H265'})"
|
|
3623
3648
|
if _keep_v is not None else "")
|
|
3624
3649
|
+ (f" audio pt={_keep_a}"
|
|
3625
3650
|
f" ({'PCMA' if _keep_a == 8 else 'PCMU'})"
|
|
@@ -3902,7 +3927,7 @@ class _SdesOpenMixin:
|
|
|
3902
3927
|
_new_sdp = narrow_sdp_payload_types(
|
|
3903
3928
|
_new_sdp,
|
|
3904
3929
|
keep_video=(_first_video_pt[0]
|
|
3905
|
-
if _first_video_pt[0] in
|
|
3930
|
+
if _first_video_pt[0] in _SDP_VIDEO_PTS else None),
|
|
3906
3931
|
keep_audio=(_first_audio_pt[0]
|
|
3907
3932
|
if _first_audio_pt[0] in (0, 8) else None),
|
|
3908
3933
|
)
|
{python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/aidot_cameras/camera/webrtc_open.py
RENAMED
|
@@ -237,16 +237,20 @@ class _WebRTCOpenMixin:
|
|
|
237
237
|
# Battery cameras must have their live-stream session provisioned by the
|
|
238
238
|
# cloud BEFORE MQTT signaling, or the camera rejects livePlayReq with
|
|
239
239
|
# -50019 ("not ready") and never runs ICE -> no media. App parity:
|
|
240
|
-
# KVSPreConnectStrategy.fetchKvsParams calls liveStreamParam first
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
#
|
|
246
|
-
#
|
|
240
|
+
# KVSPreConnectStrategy.fetchKvsParams calls liveStreamParam first, which
|
|
241
|
+
# provisions the camera's session toward AWS KVS. DEFAULT OFF: we stream
|
|
242
|
+
# over MQTT/SDES, not KVS, and on A001513 "L2" cameras this pre-connect
|
|
243
|
+
# DIVERTS the camera's media to KVS - the SDES bridge then receives no
|
|
244
|
+
# video RTP at all (validated live: with the call the L2 serves nothing;
|
|
245
|
+
# with AIDOT_LIVESTREAM_PARAM=0 it streams h264 1280x960 + PCMA, matching
|
|
246
|
+
# the pre-0.7 behaviour that always worked). It was added under #43 to
|
|
247
|
+
# cure a -50019 ("not ready") livePlayResp, but -50019 is benign - mains
|
|
248
|
+
# cameras emit it too and recover via ICE - so the pre-connect fixed a
|
|
249
|
+
# non-bug at the cost of the battery live view. Kept as an opt-in escape
|
|
250
|
+
# hatch: start_keepalive(live_stream_param=True) or AIDOT_LIVESTREAM_PARAM=1.
|
|
247
251
|
_lsp = getattr(self, "_live_stream_param_opt", None)
|
|
248
252
|
if _lsp is None:
|
|
249
|
-
_lsp = os.environ.get("AIDOT_LIVESTREAM_PARAM", "
|
|
253
|
+
_lsp = os.environ.get("AIDOT_LIVESTREAM_PARAM", "0") != "0"
|
|
250
254
|
if self.is_battery_camera and _lsp:
|
|
251
255
|
_lsp_ok = await self._async_fetch_live_stream_param()
|
|
252
256
|
_LOGGER.debug("camera %s: liveStreamParam provisioned ok=%s",
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.12.
|
|
7
|
+
version = "0.12.15"
|
|
8
8
|
description = "Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
{python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15/python_aidot_cameras.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.15
|
|
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
|
|
@@ -69,10 +69,12 @@ python_aidot_cameras.egg-info/top_level.txt
|
|
|
69
69
|
tests/test_aioice_compat.py
|
|
70
70
|
tests/test_alarm_event.py
|
|
71
71
|
tests/test_backoff.py
|
|
72
|
+
tests/test_battery_skip_turn.py
|
|
72
73
|
tests/test_deferred_hardening.py
|
|
73
74
|
tests/test_device_login_guard.py
|
|
74
75
|
tests/test_device_user_info_cache.py
|
|
75
76
|
tests/test_dormant_camera_cleanup.py
|
|
77
|
+
tests/test_dtls_mux_terminal_write.py
|
|
76
78
|
tests/test_dtls_not_ready_burst.py
|
|
77
79
|
tests/test_dtls_pinning.py
|
|
78
80
|
tests/test_dtls_serve_decode_skip.py
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""A battery SDES camera must NEVER skip the TURN relay pre-allocation.
|
|
2
|
+
|
|
3
|
+
A battery camera sleeps and is woken through the cloud, so its media returns
|
|
4
|
+
over the TURN relay, not a host-direct LAN path. Skipping the relay (the
|
|
5
|
+
LAN-direct latency optimization) leaves the camera with no reachable path back
|
|
6
|
+
and it sends no media at all. Validated live on an A001513: with the relay it
|
|
7
|
+
streams h264 1280x960; with skip_turn it serves nothing. The opt/env must not be
|
|
8
|
+
able to turn the relay off for a battery camera; the optimization applies only to
|
|
9
|
+
mains SDES cameras that actually have a host candidate on the HA segment.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
15
|
+
|
|
16
|
+
import aidot_cameras.camera.client as cc
|
|
17
|
+
|
|
18
|
+
_CAM = next(v for v in vars(cc).values()
|
|
19
|
+
if isinstance(v, type) and "_resolve_sdes_skip_turn" in v.__dict__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Info:
|
|
23
|
+
def __init__(self, model_id):
|
|
24
|
+
self.model_id = model_id
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cam(battery, **attrs):
|
|
28
|
+
c = _CAM.__new__(_CAM)
|
|
29
|
+
# is_battery_camera is a property derived from info.model_id.
|
|
30
|
+
c.info = _Info("LK.IPC.A001513" if battery else "LK.IPC.A001064")
|
|
31
|
+
for k, v in attrs.items():
|
|
32
|
+
setattr(c, k, v)
|
|
33
|
+
return c
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_battery_never_skips_turn_even_with_opt():
|
|
37
|
+
c = _cam(True, _sdes_skip_turn_opt=True)
|
|
38
|
+
assert c._resolve_sdes_skip_turn() is False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_battery_never_skips_turn_even_with_env(monkeypatch):
|
|
42
|
+
monkeypatch.setenv("AIDOT_SDES_SKIP_TURN_PREALLOC", "1")
|
|
43
|
+
c = _cam(True)
|
|
44
|
+
assert c._resolve_sdes_skip_turn() is False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_battery_never_skips_turn_with_fast_attempt_override():
|
|
48
|
+
c = _cam(True, _fast_attempt_override=True)
|
|
49
|
+
assert c._resolve_sdes_skip_turn() is False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_mains_still_honors_the_opt():
|
|
53
|
+
assert _cam(False, _sdes_skip_turn_opt=True)._resolve_sdes_skip_turn() is True
|
|
54
|
+
assert _cam(False, _sdes_skip_turn_opt=False)._resolve_sdes_skip_turn() is False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_mains_default_off(monkeypatch):
|
|
58
|
+
monkeypatch.delenv("AIDOT_SDES_SKIP_TURN_PREALLOC", raising=False)
|
|
59
|
+
assert _cam(False)._resolve_sdes_skip_turn() is False
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""A failed container write must END the DTLS mux thread, not be swallowed.
|
|
2
|
+
|
|
3
|
+
Regression for the abandoned-mux-thread leak: teardown's join(2.0) gives up on
|
|
4
|
+
a thread blocked in a pipe write; once that pipe's ffmpeg dies the write raises
|
|
5
|
+
EPIPE forever. The old per-packet swallow kept the abandoned thread looping,
|
|
6
|
+
stealing frames from the SHARED vq/aq that the next cycle's thread needed - the
|
|
7
|
+
new ffmpeg starved at its input probe and never bound its -listen port (the
|
|
8
|
+
field "dead DTLS video" of 2026-07). A dead write now exits the thread and
|
|
9
|
+
leaves the queues alone.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
av = pytest.importorskip("av")
|
|
20
|
+
|
|
21
|
+
from aidot_cameras.camera.protocol import _dtls_av_mux_run
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _h264_payloads(n=60):
|
|
25
|
+
"""Real encoded H.264 access units (the mux needs valid-ish annex-b).
|
|
26
|
+
|
|
27
|
+
Noise frames compress terribly on purpose: the total must exceed PyAV's
|
|
28
|
+
~32KB avio buffer so the container performs REAL pipe writes (a smaller
|
|
29
|
+
payload sits in the buffer forever and never observes the dead pipe).
|
|
30
|
+
"""
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
rng = np.random.default_rng(7)
|
|
34
|
+
enc = av.CodecContext.create("libx264", "w")
|
|
35
|
+
enc.width, enc.height, enc.pix_fmt = 320, 240, "yuv420p"
|
|
36
|
+
from fractions import Fraction
|
|
37
|
+
|
|
38
|
+
enc.time_base = Fraction(1, 15)
|
|
39
|
+
enc.options = {"tune": "zerolatency"}
|
|
40
|
+
out = []
|
|
41
|
+
for i in range(n):
|
|
42
|
+
img = rng.integers(0, 255, (240, 320, 3), dtype=np.uint8)
|
|
43
|
+
fr = av.VideoFrame.from_ndarray(img, format="rgb24").reformat(format="yuv420p")
|
|
44
|
+
fr.pts = i
|
|
45
|
+
for p in enc.encode(fr):
|
|
46
|
+
out.append((bytes(p), bool(p.is_keyframe)))
|
|
47
|
+
for p in enc.encode(None):
|
|
48
|
+
out.append((bytes(p), bool(p.is_keyframe)))
|
|
49
|
+
assert sum(len(d) for d, _ in out) > 100_000
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_dead_pipe_ends_mux_thread_and_spares_queues():
|
|
54
|
+
payloads = _h264_payloads()
|
|
55
|
+
assert payloads and payloads[0][1], "first packet should be a keyframe"
|
|
56
|
+
|
|
57
|
+
vq: queue.Queue = queue.Queue()
|
|
58
|
+
aq: queue.Queue = queue.Queue()
|
|
59
|
+
ts = 0
|
|
60
|
+
for data, kf in payloads:
|
|
61
|
+
vq.put((data, ts, kf))
|
|
62
|
+
ts += 6000
|
|
63
|
+
|
|
64
|
+
r, w = os.pipe()
|
|
65
|
+
os.close(r) # reader already gone -> every flushed write is EPIPE
|
|
66
|
+
wfile = os.fdopen(w, "wb", buffering=0)
|
|
67
|
+
progress = [time.monotonic()]
|
|
68
|
+
stop_flag = threading.Event()
|
|
69
|
+
|
|
70
|
+
t = threading.Thread(
|
|
71
|
+
target=_dtls_av_mux_run, args=(vq, aq, wfile, progress, stop_flag), daemon=True
|
|
72
|
+
)
|
|
73
|
+
t.start()
|
|
74
|
+
t.join(timeout=10.0)
|
|
75
|
+
|
|
76
|
+
# The thread must have exited on its own (stop_flag was never set).
|
|
77
|
+
assert not t.is_alive(), "mux thread must end on a dead pipe, not loop forever"
|
|
78
|
+
assert not stop_flag.is_set()
|
|
79
|
+
# And it must not have drained the whole queue into the dead pipe: once the
|
|
80
|
+
# avio buffer first flushes and EPIPEs, remaining frames stay for the next
|
|
81
|
+
# cycle's consumer. (The avio buffer absorbs the first ~32KB, so a few
|
|
82
|
+
# packets are legitimately consumed before the first real write.)
|
|
83
|
+
assert not vq.empty(), "a dead mux must leave queued frames for the next cycle"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_healthy_pipe_still_muxes():
|
|
87
|
+
payloads = _h264_payloads()
|
|
88
|
+
vq: queue.Queue = queue.Queue()
|
|
89
|
+
aq: queue.Queue = queue.Queue()
|
|
90
|
+
ts = 0
|
|
91
|
+
for data, kf in payloads:
|
|
92
|
+
vq.put((data, ts, kf))
|
|
93
|
+
ts += 6000
|
|
94
|
+
|
|
95
|
+
r, w = os.pipe()
|
|
96
|
+
got = [0]
|
|
97
|
+
|
|
98
|
+
def drain():
|
|
99
|
+
while True:
|
|
100
|
+
try:
|
|
101
|
+
b = os.read(r, 65536)
|
|
102
|
+
except OSError:
|
|
103
|
+
return
|
|
104
|
+
if not b:
|
|
105
|
+
return
|
|
106
|
+
got[0] += len(b)
|
|
107
|
+
|
|
108
|
+
threading.Thread(target=drain, daemon=True).start()
|
|
109
|
+
wfile = os.fdopen(w, "wb", buffering=0)
|
|
110
|
+
progress = [time.monotonic()]
|
|
111
|
+
stop_flag = threading.Event()
|
|
112
|
+
t = threading.Thread(
|
|
113
|
+
target=_dtls_av_mux_run, args=(vq, aq, wfile, progress, stop_flag), daemon=True
|
|
114
|
+
)
|
|
115
|
+
t.start()
|
|
116
|
+
time.sleep(1.5)
|
|
117
|
+
# REGRESSION (interleave stall): with an AAC stream declared but zero audio
|
|
118
|
+
# packets, libavformat used to buffer ALL video in its interleave queue -
|
|
119
|
+
# bytes only appeared at close(). They must flow DURING muxing.
|
|
120
|
+
assert got[0] > 10_000, (
|
|
121
|
+
"video must reach the pipe while muxing, without any audio packets"
|
|
122
|
+
)
|
|
123
|
+
stop_flag.set()
|
|
124
|
+
t.join(timeout=10.0)
|
|
125
|
+
assert not t.is_alive()
|
|
126
|
+
assert vq.empty(), "healthy mux consumes the queue"
|
{python_aidot_cameras-0.12.13 → python_aidot_cameras-0.12.15}/tests/test_live_stream_param.py
RENAMED
|
@@ -73,3 +73,51 @@ if __name__ == "__main__":
|
|
|
73
73
|
print(f"FAIL {_k}")
|
|
74
74
|
traceback.print_exc()
|
|
75
75
|
raise SystemExit(1 if _fail else 0)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --- default OFF regression --------------------------------------------------
|
|
79
|
+
# The liveStreamParam KVS pre-connect is OFF by default. It was added under #43
|
|
80
|
+
# to cure a -50019 livePlayResp, but -50019 is benign (mains cameras emit it and
|
|
81
|
+
# recover via ICE), and on A001513 "L2" cameras the pre-connect diverts the
|
|
82
|
+
# camera's media to AWS KVS so the SDES bridge receives no video RTP. Validated
|
|
83
|
+
# live: with the pre-connect the L2 serves nothing; with it off the L2 streams
|
|
84
|
+
# h264 1280x960 + PCMA. Lock the default so it cannot silently flip back on.
|
|
85
|
+
import asyncio
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resolve_lsp(opt, env):
|
|
89
|
+
# Mirror the gate in webrtc_open._async_open_webrtc_stream_impl.
|
|
90
|
+
_lsp = opt
|
|
91
|
+
if _lsp is None:
|
|
92
|
+
_lsp = env.get("AIDOT_LIVESTREAM_PARAM", "0") != "0"
|
|
93
|
+
return _lsp
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_liveStreamParam_defaults_off():
|
|
97
|
+
assert _resolve_lsp(None, {}) is False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_liveStreamParam_env_opt_in():
|
|
101
|
+
assert _resolve_lsp(None, {"AIDOT_LIVESTREAM_PARAM": "1"}) is True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_liveStreamParam_explicit_opt_wins_over_env():
|
|
105
|
+
assert _resolve_lsp(True, {"AIDOT_LIVESTREAM_PARAM": "0"}) is True
|
|
106
|
+
assert _resolve_lsp(False, {"AIDOT_LIVESTREAM_PARAM": "1"}) is False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_open_path_skips_arm_by_default(monkeypatch):
|
|
110
|
+
# The real gate: a battery camera with no opt/env must NOT call the arm.
|
|
111
|
+
called = {"n": 0}
|
|
112
|
+
|
|
113
|
+
async def _fake_arm(self):
|
|
114
|
+
called["n"] += 1
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
# Exercise the exact resolution + guard the open path uses.
|
|
118
|
+
monkeypatch.delenv("AIDOT_LIVESTREAM_PARAM", raising=False)
|
|
119
|
+
is_battery = True
|
|
120
|
+
_lsp = _resolve_lsp(None, os.environ)
|
|
121
|
+
if is_battery and _lsp:
|
|
122
|
+
asyncio.get_event_loop().run_until_complete(_fake_arm(object()))
|
|
123
|
+
assert called["n"] == 0, "arm must not run by default on a battery camera"
|