python-aidot-cameras 0.12.14__tar.gz → 0.12.16__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.14/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.16}/PKG-INFO +1 -1
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/client.py +71 -5
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/sdes_open.py +57 -32
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/webrtc_open.py +46 -9
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/pyproject.toml +1 -1
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
- python_aidot_cameras-0.12.16/tests/test_battery_skip_turn.py +59 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_live_stream_param.py +48 -0
- python_aidot_cameras-0.12.16/tests/test_peerid_reuse.py +69 -0
- python_aidot_cameras-0.12.16/tests/test_sdes_terminal_ack.py +73 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdp_payload_type_narrowing.py +68 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/LICENSE +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/README.md +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/constants.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/protocol.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/sdes.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/client.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/device_client.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/setup.cfg +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dormant_camera_cleanup.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_mux_terminal_write.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/tests/test_wait_or_event.py +0 -0
{python_aidot_cameras-0.12.14/python_aidot_cameras.egg-info → python_aidot_cameras-0.12.16}/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.16
|
|
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.14 → python_aidot_cameras-0.12.16}/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
|
|
@@ -3049,11 +3057,30 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3049
3057
|
# lifetime; an integration reload / restart re-probes the fast path.
|
|
3050
3058
|
_fast_failed = bool(getattr(self, "_fast_path_unavailable", False))
|
|
3051
3059
|
|
|
3060
|
+
# Reuse ONE peerid across the retries of this loop instead of minting a
|
|
3061
|
+
# fresh one per attempt. A fresh peerid = a new camera-side session, and
|
|
3062
|
+
# the camera frees old ones only slowly, so mint-per-retry stacks up
|
|
3063
|
+
# sessions on a failing battery camera faster than they drain (measured:
|
|
3064
|
+
# this is how the L2 wedged into a wake-then-sleep loop). Mirrors the
|
|
3065
|
+
# official app, which resends within one session. Rotate to a fresh
|
|
3066
|
+
# peerid after a session that actually delivered media (so the next view
|
|
3067
|
+
# is a clean session) or after _PEERID_MAX_REUSE consecutive dead
|
|
3068
|
+
# attempts (so a poisoned peerid can't wedge us permanently).
|
|
3069
|
+
_PEERID_MAX_REUSE = 3
|
|
3070
|
+
_loop_peer_id = self.generate_webrtc_peer_id(live_type=2, stream_id=0,
|
|
3071
|
+
sdes=True)
|
|
3072
|
+
_peer_reuses = 0
|
|
3073
|
+
|
|
3052
3074
|
while self._streaming_active:
|
|
3053
3075
|
if self._serve_relay is not None:
|
|
3054
3076
|
# Clear any stale backend from a prior session; the open below
|
|
3055
3077
|
# points the relay at this session's fresh internal ffmpeg port.
|
|
3056
3078
|
self._serve_relay.set_backend(None)
|
|
3079
|
+
if _peer_reuses >= _PEERID_MAX_REUSE:
|
|
3080
|
+
_loop_peer_id = self.generate_webrtc_peer_id(
|
|
3081
|
+
live_type=2, stream_id=0, sdes=True)
|
|
3082
|
+
_peer_reuses = 0
|
|
3083
|
+
_peer_reuses += 1
|
|
3057
3084
|
_use_fast = self._adaptive_next_fast(_adaptive, _fast_failed)
|
|
3058
3085
|
if _adaptive:
|
|
3059
3086
|
self._fast_attempt_override = _use_fast
|
|
@@ -3061,10 +3088,31 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3061
3088
|
session = await self.async_open_webrtc_stream(
|
|
3062
3089
|
rtsp_push_url=self._keepalive_rtsp_url,
|
|
3063
3090
|
timeout=(_FAST_OPEN_TIMEOUT if _use_fast else 120.0),
|
|
3091
|
+
reuse_peer_id=_loop_peer_id,
|
|
3064
3092
|
)
|
|
3065
3093
|
except asyncio.CancelledError:
|
|
3066
3094
|
self._fast_attempt_override = None
|
|
3067
3095
|
return
|
|
3096
|
+
except AidotCameraBusy as busy:
|
|
3097
|
+
# The camera itself said it has no free session (-50002 max
|
|
3098
|
+
# concurrent streams / -50015). Each retry mints a NEW peerid and
|
|
3099
|
+
# so registers ANOTHER camera-side session, which the camera only
|
|
3100
|
+
# releases slowly - so retrying on the short backoff makes the
|
|
3101
|
+
# shortage worse and can wedge a battery camera into a
|
|
3102
|
+
# wake-then-sleep loop that serves nothing. Wait out the release
|
|
3103
|
+
# window instead. (The DTLS loops have always done this; the SDES
|
|
3104
|
+
# loop silently did not.)
|
|
3105
|
+
self._fast_attempt_override = None
|
|
3106
|
+
_LOGGER.warning(
|
|
3107
|
+
"SDES keepalive: camera %s refused the stream (%s) - "
|
|
3108
|
+
"backing off %.0fs so its sessions can be released",
|
|
3109
|
+
self.device_id, busy, _MAX_DELAY,
|
|
3110
|
+
)
|
|
3111
|
+
try:
|
|
3112
|
+
await asyncio.sleep(_MAX_DELAY)
|
|
3113
|
+
except asyncio.CancelledError:
|
|
3114
|
+
return
|
|
3115
|
+
continue
|
|
3068
3116
|
except Exception as exc:
|
|
3069
3117
|
self._fast_attempt_override = None
|
|
3070
3118
|
if _use_fast:
|
|
@@ -3189,6 +3237,13 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3189
3237
|
# Adaptive bookkeeping: a fast attempt that never delivered media
|
|
3190
3238
|
# latches the loop onto the full relay path for its remaining opens.
|
|
3191
3239
|
_healthy = session.last_media_monotonic > 0.0
|
|
3240
|
+
if _healthy:
|
|
3241
|
+
# A session that delivered media is a real, cleanly-used session;
|
|
3242
|
+
# the next open should be a fresh one rather than re-offering on a
|
|
3243
|
+
# peerid the camera has already finished with.
|
|
3244
|
+
_loop_peer_id = self.generate_webrtc_peer_id(
|
|
3245
|
+
live_type=2, stream_id=0, sdes=True)
|
|
3246
|
+
_peer_reuses = 0
|
|
3192
3247
|
if _use_fast and not _healthy and not _fast_failed:
|
|
3193
3248
|
_LOGGER.info(
|
|
3194
3249
|
"SDES adaptive[%s]: fast attempt delivered no media - "
|
|
@@ -3571,7 +3626,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3571
3626
|
|
|
3572
3627
|
Per-camera ``sdes_skip_turn`` (set via start_keepalive) wins; else the
|
|
3573
3628
|
``AIDOT_SDES_SKIP_TURN_PREALLOC`` env (truthy = 1/true/yes/on), default
|
|
3574
|
-
off. Only consulted for SDES cameras.
|
|
3629
|
+
off. Only consulted for SDES cameras.
|
|
3630
|
+
|
|
3631
|
+
NEVER skipped for a battery camera, whatever the opt/env say: a battery
|
|
3632
|
+
camera sleeps and is woken through the cloud, so it is reached over the
|
|
3633
|
+
TURN relay rather than a host-direct LAN path. Skipping the relay
|
|
3634
|
+
pre-allocation there means the camera has no reachable path back and
|
|
3635
|
+
sends no media at all (validated live on an A001513: with the relay it
|
|
3636
|
+
streams h264 1280x960, with skip_turn it serves nothing). The LAN-direct
|
|
3637
|
+
latency win only applies to a mains camera that actually has a host
|
|
3638
|
+
candidate on the HA segment."""
|
|
3639
|
+
if getattr(self, "is_battery_camera", False):
|
|
3640
|
+
return False
|
|
3575
3641
|
opt = getattr(self, "_sdes_skip_turn_opt", None)
|
|
3576
3642
|
if opt is not None:
|
|
3577
3643
|
return bool(opt)
|
{python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/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.14 → python_aidot_cameras-0.12.16}/aidot_cameras/camera/webrtc_open.py
RENAMED
|
@@ -159,6 +159,7 @@ class _WebRTCOpenMixin:
|
|
|
159
159
|
rtsp_push_url: Optional[str] = None,
|
|
160
160
|
talk_pcm_provider: "Optional[Callable[[], Optional[bytes]]]" = None,
|
|
161
161
|
talk: bool = False,
|
|
162
|
+
reuse_peer_id: Optional[str] = None,
|
|
162
163
|
) -> "WebRTCSession":
|
|
163
164
|
"""Open a liveType=2 WebRTC stream via MQTT signaling.
|
|
164
165
|
|
|
@@ -237,16 +238,20 @@ class _WebRTCOpenMixin:
|
|
|
237
238
|
# Battery cameras must have their live-stream session provisioned by the
|
|
238
239
|
# cloud BEFORE MQTT signaling, or the camera rejects livePlayReq with
|
|
239
240
|
# -50019 ("not ready") and never runs ICE -> no media. App parity:
|
|
240
|
-
# KVSPreConnectStrategy.fetchKvsParams calls liveStreamParam first
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
#
|
|
246
|
-
#
|
|
241
|
+
# KVSPreConnectStrategy.fetchKvsParams calls liveStreamParam first, which
|
|
242
|
+
# provisions the camera's session toward AWS KVS. DEFAULT OFF: we stream
|
|
243
|
+
# over MQTT/SDES, not KVS, and on A001513 "L2" cameras this pre-connect
|
|
244
|
+
# DIVERTS the camera's media to KVS - the SDES bridge then receives no
|
|
245
|
+
# video RTP at all (validated live: with the call the L2 serves nothing;
|
|
246
|
+
# with AIDOT_LIVESTREAM_PARAM=0 it streams h264 1280x960 + PCMA, matching
|
|
247
|
+
# the pre-0.7 behaviour that always worked). It was added under #43 to
|
|
248
|
+
# cure a -50019 ("not ready") livePlayResp, but -50019 is benign - mains
|
|
249
|
+
# cameras emit it too and recover via ICE - so the pre-connect fixed a
|
|
250
|
+
# non-bug at the cost of the battery live view. Kept as an opt-in escape
|
|
251
|
+
# hatch: start_keepalive(live_stream_param=True) or AIDOT_LIVESTREAM_PARAM=1.
|
|
247
252
|
_lsp = getattr(self, "_live_stream_param_opt", None)
|
|
248
253
|
if _lsp is None:
|
|
249
|
-
_lsp = os.environ.get("AIDOT_LIVESTREAM_PARAM", "
|
|
254
|
+
_lsp = os.environ.get("AIDOT_LIVESTREAM_PARAM", "0") != "0"
|
|
250
255
|
if self.is_battery_camera and _lsp:
|
|
251
256
|
_lsp_ok = await self._async_fetch_live_stream_param()
|
|
252
257
|
_LOGGER.debug("camera %s: liveStreamParam provisioned ok=%s",
|
|
@@ -493,7 +498,14 @@ class _WebRTCOpenMixin:
|
|
|
493
498
|
_dtls_fallback_ok = str(_cam_props.get("isDTLS", "1")) != "0"
|
|
494
499
|
|
|
495
500
|
device_id = self.device_id
|
|
496
|
-
|
|
501
|
+
# A fresh peerid mints a NEW camera-side session, and the camera releases
|
|
502
|
+
# old ones only slowly (~3-4 min measured), so minting one on every retry
|
|
503
|
+
# of a failing loop stacks up sessions faster than they drain and can wedge
|
|
504
|
+
# a battery camera. The official app instead reuses one peerid and resends
|
|
505
|
+
# the same offer within a single session. ``reuse_peer_id`` lets a caller
|
|
506
|
+
# (the SDES keepalive loop) hold one peerid across retries; None keeps the
|
|
507
|
+
# historical mint-per-attempt behaviour for every other caller.
|
|
508
|
+
peer_id = reuse_peer_id or self.generate_webrtc_peer_id(
|
|
497
509
|
live_type=2, stream_id=stream_id, sdes=use_sdes
|
|
498
510
|
)
|
|
499
511
|
loop = asyncio.get_running_loop()
|
|
@@ -1208,7 +1220,26 @@ class _WebRTCOpenMixin:
|
|
|
1208
1220
|
# Branch: SDES-SRTP cameras use ffmpeg; DTLS cameras use aiortc
|
|
1209
1221
|
# ------------------------------------------------------------------ #
|
|
1210
1222
|
if use_sdes:
|
|
1223
|
+
def _raise_if_camera_refused():
|
|
1224
|
+
"""Surface a terminal webrtcResp ack on the SDES path too.
|
|
1225
|
+
|
|
1226
|
+
GAP D was only wired into the DTLS branch: the shared message
|
|
1227
|
+
handler records -50002 (max concurrent streams) / -50015 into
|
|
1228
|
+
``terminal_error_fut`` for BOTH transports, but only the DTLS
|
|
1229
|
+
connect ever read it. So when a battery camera answered "no
|
|
1230
|
+
free session", the SDES keepalive loop treated it as a generic
|
|
1231
|
+
failure and retried on the short backoff - hammering the very
|
|
1232
|
+
camera that had just said it was full, and registering yet
|
|
1233
|
+
another peerid each time. Raising AidotCameraBusy lets the
|
|
1234
|
+
loop back off for the camera's release window instead.
|
|
1235
|
+
"""
|
|
1236
|
+
if terminal_error_fut.done():
|
|
1237
|
+
_code, _desc = terminal_error_fut.result()
|
|
1238
|
+
_status(f"camera refused the stream (code={_code}) - not retrying")
|
|
1239
|
+
raise AidotCameraBusy(_code, _desc)
|
|
1240
|
+
|
|
1211
1241
|
try:
|
|
1242
|
+
_raise_if_camera_refused()
|
|
1212
1243
|
_sdes_session = await self._open_sdes_stream(
|
|
1213
1244
|
peer_id=peer_id,
|
|
1214
1245
|
user_id=user_id,
|
|
@@ -1244,7 +1275,13 @@ class _WebRTCOpenMixin:
|
|
|
1244
1275
|
# DTLS-fallback path below keeps the slot for its own hand-off.)
|
|
1245
1276
|
self._release_stream_drain_to_session()
|
|
1246
1277
|
return _sdes_session
|
|
1278
|
+
except AidotCameraBusy:
|
|
1279
|
+
raise
|
|
1247
1280
|
except CameraMixin._SdesNoAnswerError:
|
|
1281
|
+
# A terminal refusal that lands while we were waiting surfaces
|
|
1282
|
+
# as a no-answer; check before spending a DTLS fallback on a
|
|
1283
|
+
# camera that has already told us it has no free session.
|
|
1284
|
+
_raise_if_camera_refused()
|
|
1248
1285
|
# Camera reported enableSdes='1' but did not respond to our SDES
|
|
1249
1286
|
# offer. iOS telemetry shows models such as LK.IPC.A001064 can
|
|
1250
1287
|
# have an incorrectly set enableSdes property while actually
|
|
@@ -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.16"
|
|
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.14 → python_aidot_cameras-0.12.16/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.16
|
|
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,6 +69,7 @@ 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
|
|
@@ -105,6 +106,7 @@ tests/test_no_undefined_names.py
|
|
|
105
106
|
tests/test_offline_keepalive_pause.py
|
|
106
107
|
tests/test_open_fail_logger.py
|
|
107
108
|
tests/test_open_gate_delay.py
|
|
109
|
+
tests/test_peerid_reuse.py
|
|
108
110
|
tests/test_persistent_mqtt.py
|
|
109
111
|
tests/test_persistent_mqtt_robustness.py
|
|
110
112
|
tests/test_playback_tls.py
|
|
@@ -122,6 +124,7 @@ tests/test_sdes_serve_cmd.py
|
|
|
122
124
|
tests/test_sdes_sprop.py
|
|
123
125
|
tests/test_sdes_talk.py
|
|
124
126
|
tests/test_sdes_teardown_exit_log.py
|
|
127
|
+
tests/test_sdes_terminal_ack.py
|
|
125
128
|
tests/test_sdes_watchdog.py
|
|
126
129
|
tests/test_sdp_payload_type_narrowing.py
|
|
127
130
|
tests/test_security_hardening.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
|
{python_aidot_cameras-0.12.14 → python_aidot_cameras-0.12.16}/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"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The SDES keepalive loop reuses one peerid across retries instead of minting
|
|
2
|
+
a fresh one every attempt.
|
|
3
|
+
|
|
4
|
+
A fresh peerid registers a NEW camera-side session, and the camera releases old
|
|
5
|
+
ones only slowly (~3-4 min measured), so minting one per retry on a failing loop
|
|
6
|
+
stacks up sessions faster than they drain - which is how a battery A001513 gets
|
|
7
|
+
wedged into a wake-then-sleep loop that serves nothing. The official app reuses a
|
|
8
|
+
peerid and resends within one session. This locks:
|
|
9
|
+
- the impl accepts reuse_peer_id and uses it verbatim when given;
|
|
10
|
+
- it still mints a fresh one when not given (unchanged for every other caller);
|
|
11
|
+
- the SDES keepalive loop holds one across retries and rotates it after a
|
|
12
|
+
media-delivering session or a reuse cap.
|
|
13
|
+
"""
|
|
14
|
+
import inspect
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
19
|
+
|
|
20
|
+
import aidot_cameras.camera.client as cc
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _impl_signature():
|
|
24
|
+
for cls in vars(cc).values():
|
|
25
|
+
if isinstance(cls, type) and "_async_open_webrtc_stream_impl" in cls.__dict__:
|
|
26
|
+
return inspect.signature(cls.__dict__["_async_open_webrtc_stream_impl"])
|
|
27
|
+
raise AssertionError("impl not found")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fn_source(qualname):
|
|
31
|
+
for cls in vars(cc).values():
|
|
32
|
+
if isinstance(cls, type) and qualname in cls.__dict__:
|
|
33
|
+
return inspect.getsource(cls.__dict__[qualname])
|
|
34
|
+
raise AssertionError(f"{qualname} not found")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_impl_accepts_reuse_peer_id_defaulting_to_none():
|
|
38
|
+
p = _impl_signature().parameters.get("reuse_peer_id")
|
|
39
|
+
assert p is not None, "impl must accept reuse_peer_id"
|
|
40
|
+
assert p.default is None, "default None preserves mint-per-call for other callers"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_impl_uses_reuse_peer_id_verbatim_when_given():
|
|
44
|
+
src = _fn_source("_async_open_webrtc_stream_impl")
|
|
45
|
+
# peer_id = reuse_peer_id or generate_webrtc_peer_id(...)
|
|
46
|
+
assert "reuse_peer_id or" in src, (
|
|
47
|
+
"when a peerid is supplied it must be used as-is, not regenerated")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_sdes_keepalive_loop_holds_and_rotates_one_peerid():
|
|
51
|
+
src = _fn_source("_sdes_keepalive_loop_inner")
|
|
52
|
+
assert "reuse_peer_id=" in src, "the SDES loop must pass a reused peerid"
|
|
53
|
+
assert "_loop_peer_id" in src
|
|
54
|
+
# rotate after a media-delivering session and after a reuse cap
|
|
55
|
+
assert "_PEERID_MAX_REUSE" in src, "must cap consecutive reuses"
|
|
56
|
+
assert src.count("generate_webrtc_peer_id") >= 2, (
|
|
57
|
+
"must be able to rotate to a fresh peerid (initial + rotation)")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_generate_peer_id_is_unique_per_call():
|
|
61
|
+
gen = None
|
|
62
|
+
for cls in vars(cc).values():
|
|
63
|
+
if isinstance(cls, type) and "generate_webrtc_peer_id" in cls.__dict__:
|
|
64
|
+
gen = cls.generate_webrtc_peer_id
|
|
65
|
+
break
|
|
66
|
+
assert gen is not None
|
|
67
|
+
a = gen(live_type=2, stream_id=0, sdes=True)
|
|
68
|
+
b = gen(live_type=2, stream_id=0, sdes=True)
|
|
69
|
+
assert a != b and a.endswith("_2_0_1") and b.endswith("_2_0_1")
|