python-aidot-cameras 0.17.1__tar.gz → 0.17.2__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.17.1/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.2}/PKG-INFO +4 -1
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/README.md +3 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/client.py +12 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/client.py +26 -6
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/device_client.py +61 -11
- python_aidot_cameras-0.17.2/aidot_cameras/lan_retry.py +198 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/pyproject.toml +1 -1
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2/python_aidot_cameras.egg-info}/PKG-INFO +4 -1
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/SOURCES.txt +4 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dormant_camera_cleanup.py +52 -0
- python_aidot_cameras-0.17.2/tests/test_lan_retry_reaches_the_storming_devices.py +252 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_light_active_color_mode.py +15 -6
- python_aidot_cameras-0.17.2/tests/test_login_connect_deadline.py +113 -0
- python_aidot_cameras-0.17.2/tests/test_login_reconnect_backoff.py +83 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/LICENSE +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_upstream.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/ble_gateway.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/constants.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/hwaccel.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/protocol.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/sdes.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/sdes_open.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/camera/webrtc_open.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/setup.cfg +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_answer_sdp_video_pt.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_audio_mux_offline.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_avio_request.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_avio_response_parse.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_avio_response_router.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_battery_detection.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_battery_evidence_corroboration.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_battery_skip_turn.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_ble_gateway.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_bridge_state_sentinels.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_busy_backoff.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_constants_imports_resolve.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_control_verdicts.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_device_cmd_seq_match.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_mux_terminal_write.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dtls_stdout_producer.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_env_seams.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_futile_keepalive.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_hwaccel_probe.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_ice_peer_reflexive.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_live_validate_policy.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_media_socket_rcvbuf.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_media_wait_floor.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_peerid_reuse.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_remb.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_resolution_persists.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_restart_cycle.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_scripts_importable.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_avio_dispatch.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_offer_video_pin.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_sctp_abort.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_serve_stderr_drain.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_terminal_ack.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_terminal_ack_latency.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdp_narrowing_properties.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sdp_payload_type_narrowing.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_serve_loss_tolerance.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_shared_house_seam.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sprop_capture_gate.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_sprop_staleness.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_turn_create_permission.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_video_profile_log.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_viewer_present_push_mode.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_wait_or_event.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_wake_gate_camera_evidence.py +0 -0
- {python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_wake_readiness_retry.py +0 -0
{python_aidot_cameras-0.17.1/python_aidot_cameras.egg-info → python_aidot_cameras-0.17.2}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.17.
|
|
3
|
+
Version: 0.17.2
|
|
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
|
|
@@ -178,6 +178,9 @@ audio, idle release, the sprop cache path) are documented in
|
|
|
178
178
|
| `AIDOT_DTLS_FAST_LIVEPLAY` | The DTLS (A000088) analogue: skip the `livePlayReq`-echo and `livePlayResp` waits (the dominant LAN cold-start cost) while keeping the full ICE/TURN/DTLS handshake, so remote/relay viewing is unaffected. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
|
|
179
179
|
| `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soaks cut SDES NO_MEDIA from ~57% to ~11-19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
|
|
180
180
|
| `AIDOT_SERVE_RELAY` | Hold the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
|
|
181
|
+
| `AIDOT_LOGIN_RETRY_LIMIT` | Consecutive failed LAN logins after which a device is left alone (it is retried again the next time something asks for it). Applies to every device, camera or not - the devices that hit this are lights. | `6` |
|
|
182
|
+
| `AIDOT_LOGIN_RETRY_CAP_S` | Ceiling on the exponential delay between those retries. | `60` |
|
|
183
|
+
| `AIDOT_LOGIN_CONNECT_TIMEOUT_S` | Ceiling on one LAN connect+login attempt. A device that completes the TCP handshake and then stops answering is abandoned and its socket closed rather than parking the attempt forever. | `20` |
|
|
181
184
|
| `AIDOT_LIVESTREAM_PARAM` | **No-op (ignored).** Formerly requested the cloud `liveStreamParam` pre-connect for battery cameras. That call provisions the camera toward AWS KVS, so it sends its media there instead of to this library and the live view negotiates and then shows nothing - and battery cameras were the only ones it applied to. Setting it (or `start_keepalive(live_stream_param=True)`) logs one warning and changes nothing. | ignored |
|
|
182
185
|
|
|
183
186
|
### Security hardening
|
|
@@ -147,6 +147,9 @@ audio, idle release, the sprop cache path) are documented in
|
|
|
147
147
|
| `AIDOT_DTLS_FAST_LIVEPLAY` | The DTLS (A000088) analogue: skip the `livePlayReq`-echo and `livePlayResp` waits (the dominant LAN cold-start cost) while keeping the full ICE/TURN/DTLS handshake, so remote/relay viewing is unaffected. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
|
|
148
148
|
| `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soaks cut SDES NO_MEDIA from ~57% to ~11-19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
|
|
149
149
|
| `AIDOT_SERVE_RELAY` | Hold the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
|
|
150
|
+
| `AIDOT_LOGIN_RETRY_LIMIT` | Consecutive failed LAN logins after which a device is left alone (it is retried again the next time something asks for it). Applies to every device, camera or not - the devices that hit this are lights. | `6` |
|
|
151
|
+
| `AIDOT_LOGIN_RETRY_CAP_S` | Ceiling on the exponential delay between those retries. | `60` |
|
|
152
|
+
| `AIDOT_LOGIN_CONNECT_TIMEOUT_S` | Ceiling on one LAN connect+login attempt. A device that completes the TCP handshake and then stops answering is abandoned and its socket closed rather than parking the attempt forever. | `20` |
|
|
150
153
|
| `AIDOT_LIVESTREAM_PARAM` | **No-op (ignored).** Formerly requested the cloud `liveStreamParam` pre-connect for battery cameras. That call provisions the camera toward AWS KVS, so it sends its media there instead of to this library and the live view negotiates and then shows nothing - and battery cameras were the only ones it applied to. Setting it (or `start_keepalive(live_stream_param=True)`) logs one warning and changes nothing. | ignored |
|
|
151
154
|
|
|
152
155
|
### Security hardening
|
|
@@ -3622,6 +3622,18 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
3622
3622
|
self.device_id, _no_media_streak,
|
|
3623
3623
|
)
|
|
3624
3624
|
self._streaming_active = False
|
|
3625
|
+
# Same teardown the idle-release exit does. Leaving the
|
|
3626
|
+
# go2rtc stream registered points it at a serve port with
|
|
3627
|
+
# nothing listening, so a viewer attaching to a dormant
|
|
3628
|
+
# camera gets "connection refused" instead of a clean miss -
|
|
3629
|
+
# exactly what the idle-release path exists to avoid.
|
|
3630
|
+
self._cancel_keepalive_renew()
|
|
3631
|
+
try:
|
|
3632
|
+
await self._deregister_go2rtc()
|
|
3633
|
+
except Exception:
|
|
3634
|
+
_LOGGER.debug("camera %s: go2rtc deregister after "
|
|
3635
|
+
"abandon failed", self.device_id,
|
|
3636
|
+
exc_info=True)
|
|
3625
3637
|
return
|
|
3626
3638
|
if _use_fast and not _healthy and not _fast_failed:
|
|
3627
3639
|
_LOGGER.info(
|
|
@@ -72,7 +72,7 @@ from .const import (
|
|
|
72
72
|
LOGIN_INFO_PERSISTENT_MQTT_LOCK_KEY,
|
|
73
73
|
RUNTIME_ONLY_LOGIN_INFO_KEYS,
|
|
74
74
|
)
|
|
75
|
-
from .device_client import CameraDeviceClient
|
|
75
|
+
from .device_client import CameraDeviceClient, LightDeviceClient
|
|
76
76
|
# CARRIED: drop when python-aidot#6 merges - this is upstream's DeviceStatusData
|
|
77
77
|
# plus the active_color_mode tracker (see aidot_cameras/device_client.py).
|
|
78
78
|
from .device_client import DeviceStatusData as _CarriedStatusData
|
|
@@ -791,13 +791,33 @@ class CameraClient(_UpstreamAidotClient):
|
|
|
791
791
|
|
|
792
792
|
This is the only place device clients are constructed, upstream or
|
|
793
793
|
here, and therefore the only place that decides which class to use.
|
|
794
|
-
Non-cameras
|
|
795
|
-
``
|
|
796
|
-
behavior is attached to them. (Importing this
|
|
797
|
-
camera package in - the guarantee is about
|
|
794
|
+
Non-cameras get a ``LightDeviceClient``: upstream's client plus the
|
|
795
|
+
camera-free ``LanRetryMixin``, and nothing else. No camera code RUNS in
|
|
796
|
+
their path and no camera behavior is attached to them. (Importing this
|
|
797
|
+
module still pulls the camera package in - the guarantee is about
|
|
798
|
+
execution, not imports.)
|
|
799
|
+
|
|
800
|
+
The light branch is the one place an upstream method body is
|
|
801
|
+
reproduced. Upstream's ``get_device_client`` names ``DeviceClient``
|
|
802
|
+
directly at construction, so there is no seam that lets a subclass be
|
|
803
|
+
substituted; the alternative is patching upstream's module global, which
|
|
804
|
+
would reach every consumer in the process rather than only ours. The
|
|
805
|
+
one behaviour that copy owes upstream - pushing the discovered LAN IP
|
|
806
|
+
into the client, without which a light never logs in - is covered by
|
|
807
|
+
``test_a_discovered_address_still_reaches_a_light``. It reads the map
|
|
808
|
+
through ``CameraDiscover.discovered_device``, which resolves correctly
|
|
809
|
+
on both upstream shapes (upstream's own line does not).
|
|
798
810
|
"""
|
|
799
811
|
if not _is_camera_device(device):
|
|
800
|
-
|
|
812
|
+
device_id = device.get(CONF_ID)
|
|
813
|
+
device_client = self._device_clients.get(device_id)
|
|
814
|
+
if device_client is None:
|
|
815
|
+
device_client = LightDeviceClient(device, self.login_info)
|
|
816
|
+
self._device_clients[device_id] = device_client
|
|
817
|
+
if self._discover is not None:
|
|
818
|
+
device_client.update_ip_address(
|
|
819
|
+
self._discover.discovered_device.get(device_id)
|
|
820
|
+
)
|
|
801
821
|
# CARRIED: drop when python-aidot#6 merges
|
|
802
822
|
self._carry_active_color_mode(device_client)
|
|
803
823
|
return device_client
|
|
@@ -8,16 +8,22 @@ edited. This module layers the camera surface on top:
|
|
|
8
8
|
`CameraDeviceInformation` on them and because the pre-inversion module exposed
|
|
9
9
|
them under these names. `DeviceInformation` additionally accepts the RAW cloud
|
|
10
10
|
device dict, which upstream's typed constructor does not.
|
|
11
|
-
* `
|
|
12
|
-
|
|
13
|
-
is
|
|
11
|
+
* `LightDeviceClient` - upstream `DeviceClient` + `LanRetryMixin` and nothing
|
|
12
|
+
else. What every non-camera device gets. It carries no camera code, by
|
|
13
|
+
construction: it is declared above the camera import, and the mixin lives in
|
|
14
|
+
its own camera-free module.
|
|
15
|
+
* `CameraDeviceClient` - `CameraMixin` + `LanRetryMixin` + upstream
|
|
16
|
+
`DeviceClient`. Only the seams the camera layer actually needs are
|
|
17
|
+
overridden; no upstream method body is copied.
|
|
14
18
|
|
|
15
19
|
Import order matters: the two data classes must be defined BEFORE
|
|
16
20
|
`.camera.client` is imported, because `aidot_cameras.camera.models` imports them
|
|
17
21
|
back from this module at import time.
|
|
18
22
|
"""
|
|
19
23
|
|
|
24
|
+
import asyncio
|
|
20
25
|
import logging
|
|
26
|
+
import os
|
|
21
27
|
from typing import Any, Optional
|
|
22
28
|
|
|
23
29
|
from aidot.device_client import DeviceClient as _UpstreamDeviceClient
|
|
@@ -35,6 +41,20 @@ from ._upstream import (
|
|
|
35
41
|
)
|
|
36
42
|
from ._upstream import DeviceState as _UpstreamDeviceState
|
|
37
43
|
|
|
44
|
+
# The LAN retry policy. It lives in its own module because it is mixed into the
|
|
45
|
+
# plain light client as well as the camera one, and that module must never grow
|
|
46
|
+
# a camera import - see its docstring. The names are re-exported below, where
|
|
47
|
+
# the policy used to be defined.
|
|
48
|
+
from .lan_retry import (
|
|
49
|
+
_LOGIN_CONNECT_TIMEOUT_S,
|
|
50
|
+
_LOGIN_RETRY_BASE_S,
|
|
51
|
+
_LOGIN_RETRY_CAP_S,
|
|
52
|
+
_LOGIN_RETRY_LIMIT,
|
|
53
|
+
LanRetryMixin,
|
|
54
|
+
_await_connect_with_deadline,
|
|
55
|
+
_next_login_retry_delay,
|
|
56
|
+
)
|
|
57
|
+
|
|
38
58
|
from .const import (
|
|
39
59
|
CONF_ATTR,
|
|
40
60
|
CONF_HARDWARE_VERSION,
|
|
@@ -55,11 +75,13 @@ from .const import (
|
|
|
55
75
|
|
|
56
76
|
_LOGGER = logging.getLogger(__name__)
|
|
57
77
|
|
|
78
|
+
|
|
58
79
|
# Upstream's base client, re-exported under its plain name. `get_device_client`
|
|
59
|
-
# hands
|
|
60
|
-
#
|
|
61
|
-
# from here rather than importing `aidot`
|
|
62
|
-
# undeclared transitive dependency. This is
|
|
80
|
+
# hands back a `LightDeviceClient` for every non-camera device, which IS one of
|
|
81
|
+
# these, so consumers (the Home Assistant integration) can keep annotating
|
|
82
|
+
# against this name - and should get it from here rather than importing `aidot`
|
|
83
|
+
# directly, which for them is an undeclared transitive dependency. This name is
|
|
84
|
+
# upstream's class itself, not a subclass.
|
|
63
85
|
DeviceClient = _UpstreamDeviceClient
|
|
64
86
|
|
|
65
87
|
# Same reasoning for the LAN session state enum: a consumer that wants to know
|
|
@@ -146,6 +168,27 @@ class DeviceInformation(_UpstreamDeviceInformation):
|
|
|
146
168
|
self.enable_cct = True
|
|
147
169
|
|
|
148
170
|
|
|
171
|
+
class LightDeviceClient(LanRetryMixin, _UpstreamDeviceClient):
|
|
172
|
+
"""Upstream's client for every non-camera device, with retries bounded.
|
|
173
|
+
|
|
174
|
+
Deliberately declared ABOVE the camera import below, and deliberately not
|
|
175
|
+
built on anything from `.camera`: this is the class a light gets, and the
|
|
176
|
+
dispatch seam guarantees no camera code runs in a light's path. A reader
|
|
177
|
+
checking that guarantee should be able to see it without leaving this page.
|
|
178
|
+
|
|
179
|
+
It exists because the LAN-login storm is a LIGHT problem. Upstream's
|
|
180
|
+
unbounded retry and its missing read timeout are defects of the TCP:10000
|
|
181
|
+
light protocol, and the six devices that produced 15,376 failed logins in
|
|
182
|
+
one run were lights. A policy carried only by `CameraDeviceClient` cannot
|
|
183
|
+
reach them - it is not in their MRO at all - which is exactly how a release
|
|
184
|
+
shipped claiming the storm was fixed while the log showed 26,229 failures
|
|
185
|
+
and zero occurrences of either new line.
|
|
186
|
+
|
|
187
|
+
Everything else stays upstream's: no method body is copied, and the mixin
|
|
188
|
+
overrides only the two seams that are defective.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
|
|
149
192
|
# --------------------------------------------------------------------------- #
|
|
150
193
|
# Camera surface (additive layer)
|
|
151
194
|
# --------------------------------------------------------------------------- #
|
|
@@ -177,12 +220,17 @@ from .camera.client import (
|
|
|
177
220
|
)
|
|
178
221
|
|
|
179
222
|
|
|
180
|
-
class CameraDeviceClient(CameraMixin, _UpstreamDeviceClient):
|
|
223
|
+
class CameraDeviceClient(CameraMixin, LanRetryMixin, _UpstreamDeviceClient):
|
|
181
224
|
"""Upstream device client with the camera surface mixed in.
|
|
182
225
|
|
|
183
|
-
`CameraMixin` comes first in the MRO so camera behavior wins
|
|
184
|
-
|
|
185
|
-
|
|
226
|
+
`CameraMixin` comes first in the MRO so camera behavior wins; it defines
|
|
227
|
+
none of the upstream connection methods, so those `super()` calls fall
|
|
228
|
+
through to `LanRetryMixin` and then to `aidot.device_client.DeviceClient`.
|
|
229
|
+
|
|
230
|
+
`LanRetryMixin` is the same policy a light gets - a camera reaching the LAN
|
|
231
|
+
login path at all is already wrong (see `async_login` below), but if one
|
|
232
|
+
ever does, it should be bounded by the same rules rather than by a second
|
|
233
|
+
copy of them.
|
|
186
234
|
"""
|
|
187
235
|
|
|
188
236
|
# Raw JSON of the frame most recently read off the wire, consumed once by
|
|
@@ -270,6 +318,8 @@ class CameraDeviceClient(CameraMixin, _UpstreamDeviceClient):
|
|
|
270
318
|
model = getattr(getattr(self, "info", None), "model_id", "") or ""
|
|
271
319
|
if "IPC" in model:
|
|
272
320
|
return
|
|
321
|
+
# LanRetryMixin is next in the MRO; it forwards to upstream and clears
|
|
322
|
+
# the consecutive-failure count on a login that got through.
|
|
273
323
|
await super().async_login()
|
|
274
324
|
|
|
275
325
|
async def close(self) -> None:
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Bounded LAN login retries for every device client, camera or not.
|
|
2
|
+
|
|
3
|
+
Upstream (`aidot.device_client`) retries a failed TCP:10000 login with no delay
|
|
4
|
+
and no ceiling, and reads the login response with no timeout at all. Both
|
|
5
|
+
defects belong to the LIGHT protocol; the devices that hit them in the field are
|
|
6
|
+
lights. So the policy lives here, in a module that imports nothing from
|
|
7
|
+
`aidot_cameras.camera` and never will - `LanRetryMixin` is mixed into the plain
|
|
8
|
+
`LightDeviceClient` as well as into `CameraDeviceClient`, and the dispatch seam's
|
|
9
|
+
guarantee that no camera code RUNS in a light's path has to survive that.
|
|
10
|
+
|
|
11
|
+
Keeping it in its own module is the enforcement: `device_client` imports the
|
|
12
|
+
camera package at module scope, so anything defined there is one edit away from
|
|
13
|
+
reaching for a camera symbol. Here, a camera import would be visible on sight.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
#: How many consecutive failed LAN logins before this device is left alone, and
|
|
24
|
+
#: the ceiling on the delay between them. Both overridable per install.
|
|
25
|
+
_LOGIN_RETRY_LIMIT = int(os.environ.get("AIDOT_LOGIN_RETRY_LIMIT", "6"))
|
|
26
|
+
_LOGIN_RETRY_CAP_S = float(os.environ.get("AIDOT_LOGIN_RETRY_CAP_S", "60"))
|
|
27
|
+
_LOGIN_RETRY_BASE_S = 1.0
|
|
28
|
+
|
|
29
|
+
#: Ceiling on one LAN connect+login attempt. Upstream has no read timeout at
|
|
30
|
+
#: all - `grep -cE "wait_for|timeout"` over its device_client returns 0 - so a
|
|
31
|
+
#: device that accepts the TCP connection and then stops answering parks
|
|
32
|
+
#: `readexactly(8)` forever, inside `connect()`.
|
|
33
|
+
_LOGIN_CONNECT_TIMEOUT_S = float(
|
|
34
|
+
os.environ.get("AIDOT_LOGIN_CONNECT_TIMEOUT_S", "20"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _await_connect_with_deadline(
|
|
38
|
+
connect_coro, timeout: float, device_id: str, on_timeout
|
|
39
|
+
) -> bool:
|
|
40
|
+
"""Await a connect attempt, bounded. True if it finished in time.
|
|
41
|
+
|
|
42
|
+
A parked attempt is worse than a failed one. `connect()`'s
|
|
43
|
+
`finally: self._connecting = False` never runs, so the client still believes
|
|
44
|
+
an attempt is in flight - which wedges both re-entry doors, because the
|
|
45
|
+
retry never spawns and a timer that did fire would hit the in-flight guard
|
|
46
|
+
and return. The device stops being managed, silently, and its socket stays
|
|
47
|
+
open. Observed: four of six devices ended that way, and all six emitted
|
|
48
|
+
their single teardown error within 3 ms of each other, one socket having
|
|
49
|
+
been held for 21 minutes.
|
|
50
|
+
|
|
51
|
+
``on_timeout`` closes the connection. Its failure is logged and swallowed:
|
|
52
|
+
this runs on the failure path already, and letting a close error replace the
|
|
53
|
+
timeout would turn a handled outcome into an unhandled one. Cancellation of
|
|
54
|
+
the caller is never swallowed - shutdown has to be able to stop this.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
await asyncio.wait_for(connect_coro, timeout)
|
|
58
|
+
return True
|
|
59
|
+
except asyncio.CancelledError:
|
|
60
|
+
raise
|
|
61
|
+
except TimeoutError: # asyncio.TimeoutError is an alias since 3.11
|
|
62
|
+
_LOGGER.warning(
|
|
63
|
+
"%s: LAN connect/login did not answer within %.0fs - abandoning "
|
|
64
|
+
"this attempt and closing the socket. Override with "
|
|
65
|
+
"AIDOT_LOGIN_CONNECT_TIMEOUT_S.", device_id, timeout,
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
await on_timeout()
|
|
69
|
+
except asyncio.CancelledError:
|
|
70
|
+
raise
|
|
71
|
+
except Exception:
|
|
72
|
+
_LOGGER.debug("%s: cleanup after connect timeout failed",
|
|
73
|
+
device_id, exc_info=True)
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _next_login_retry_delay(attempt: int) -> Optional[float]:
|
|
78
|
+
"""Seconds to wait before login attempt ``attempt``, or None to give up.
|
|
79
|
+
|
|
80
|
+
Upstream retries a failed login with no delay and no ceiling: ``login()``
|
|
81
|
+
logs the error, calls ``reset()``, and ``reset()`` ends in
|
|
82
|
+
``_schedule_reconnect()``, whose last line is
|
|
83
|
+
``asyncio.create_task(self.async_login())`` - straight back into
|
|
84
|
+
``connect()``. The ``loop.call_later(60, ...)`` on the line above never
|
|
85
|
+
fires, because the next ``reset()`` cancels ``_reconnect_handle`` at its own
|
|
86
|
+
top. So the period is the device's login round-trip, not a minute.
|
|
87
|
+
|
|
88
|
+
Measured on a live run: 8,434 of 8,434 failures were followed by that
|
|
89
|
+
device's next connect within a median of 0.295 ms - about 7.6 attempts per
|
|
90
|
+
second for one light, 15,376 across six devices, sustained until the process
|
|
91
|
+
ended. The loop never stops on its own.
|
|
92
|
+
|
|
93
|
+
Exponential from 1 s, capped, and then it stops. The first retry stays
|
|
94
|
+
prompt because a single dropped connection is ordinary and recovering from
|
|
95
|
+
it quickly is the behaviour worth keeping; what is not worth keeping is the
|
|
96
|
+
twelve-thousandth attempt.
|
|
97
|
+
"""
|
|
98
|
+
if attempt < 0 or attempt >= _LOGIN_RETRY_LIMIT:
|
|
99
|
+
return None
|
|
100
|
+
return min(_LOGIN_RETRY_BASE_S * (2 ** attempt), _LOGIN_RETRY_CAP_S)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class LanRetryMixin:
|
|
104
|
+
"""Bound the LAN login loop for whatever client this is mixed into.
|
|
105
|
+
|
|
106
|
+
Mix it in ahead of `aidot.device_client.DeviceClient`, so every `super()`
|
|
107
|
+
call below lands on upstream. Upstream calls `_schedule_reconnect` and
|
|
108
|
+
`connect` through `self`, so an override here intercepts every path into
|
|
109
|
+
them, including the `loop.call_later(60, self._schedule_reconnect)` re-arm,
|
|
110
|
+
whose bound method is resolved through this MRO too.
|
|
111
|
+
|
|
112
|
+
Nothing here knows what kind of device it is holding. That is the point:
|
|
113
|
+
the six devices that produced the storm were lights, and a policy that only
|
|
114
|
+
a camera class carries cannot reach them.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
#: Consecutive failed logins so far. Class-level so it reads correctly
|
|
118
|
+
#: before the first failure, and reset on any login that gets through.
|
|
119
|
+
_login_attempt: int = 0
|
|
120
|
+
|
|
121
|
+
#: Ceiling on one connect+login attempt. A class attribute rather than a
|
|
122
|
+
#: bare module read so a subclass (or a test) can shorten it in place.
|
|
123
|
+
_login_connect_timeout_s: float = _LOGIN_CONNECT_TIMEOUT_S
|
|
124
|
+
|
|
125
|
+
async def async_login(self) -> None:
|
|
126
|
+
"""Log in, and let a login that got through clear the failure count.
|
|
127
|
+
|
|
128
|
+
The ceiling counts CONSECUTIVE failures, so a device that drops
|
|
129
|
+
occasionally and recovers must never accumulate its way to being
|
|
130
|
+
abandoned. Upstream sets `_connect_and_login` only on the success path,
|
|
131
|
+
which makes it the honest signal to reset against.
|
|
132
|
+
"""
|
|
133
|
+
await super().async_login()
|
|
134
|
+
if getattr(self, "_connect_and_login", False):
|
|
135
|
+
self._login_attempt = 0
|
|
136
|
+
|
|
137
|
+
async def connect(self, ip_address) -> None:
|
|
138
|
+
"""Bound the attempt, so a silent device cannot park it forever.
|
|
139
|
+
|
|
140
|
+
Upstream reads the login response with no timeout, so a device that
|
|
141
|
+
completes the TCP handshake and then says nothing leaves this coroutine
|
|
142
|
+
parked inside `readexactly`, with `_connecting` still True and the
|
|
143
|
+
socket still open. See _await_connect_with_deadline.
|
|
144
|
+
|
|
145
|
+
On timeout we close through `reset()`, which is also what puts the
|
|
146
|
+
device back on the retry path - now a bounded one.
|
|
147
|
+
"""
|
|
148
|
+
ok = await _await_connect_with_deadline(
|
|
149
|
+
super().connect(ip_address),
|
|
150
|
+
self._login_connect_timeout_s,
|
|
151
|
+
getattr(self, "device_id", "?"),
|
|
152
|
+
self.reset,
|
|
153
|
+
)
|
|
154
|
+
if not ok:
|
|
155
|
+
# reset() cleared it, but the parked attempt never ran connect()'s
|
|
156
|
+
# own finally, so make the in-flight flag honest either way.
|
|
157
|
+
self._connecting = False
|
|
158
|
+
|
|
159
|
+
def _schedule_reconnect(self) -> None:
|
|
160
|
+
"""Back off between failed logins, and eventually stop.
|
|
161
|
+
|
|
162
|
+
Upstream's version re-arms a 60 s timer that never fires and then
|
|
163
|
+
immediately spawns the next login, so a device that cannot log in is
|
|
164
|
+
retried at its own round-trip rate forever - measured at ~7.6/s per
|
|
165
|
+
device, 15,376 attempts in one 25-minute run across six lights.
|
|
166
|
+
|
|
167
|
+
This replaces the immediate respawn with an exponential delay and a
|
|
168
|
+
ceiling. It deliberately does NOT call super(): the whole of upstream's
|
|
169
|
+
body is the defect - the timer that never fires and the task that fires
|
|
170
|
+
at once.
|
|
171
|
+
"""
|
|
172
|
+
if getattr(self, "_is_close", False):
|
|
173
|
+
return
|
|
174
|
+
attempt = getattr(self, "_login_attempt", 0)
|
|
175
|
+
delay = _next_login_retry_delay(attempt)
|
|
176
|
+
if delay is None:
|
|
177
|
+
_LOGGER.warning(
|
|
178
|
+
"%s: giving up LAN login after %d consecutive failures; it will "
|
|
179
|
+
"be retried when something asks for this device again. Set "
|
|
180
|
+
"AIDOT_LOGIN_RETRY_LIMIT to change the ceiling.",
|
|
181
|
+
getattr(self, "device_id", "?"), attempt,
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
self._login_attempt = attempt + 1
|
|
185
|
+
|
|
186
|
+
async def _retry() -> None:
|
|
187
|
+
try:
|
|
188
|
+
await asyncio.sleep(delay)
|
|
189
|
+
await self.async_login()
|
|
190
|
+
except asyncio.CancelledError:
|
|
191
|
+
raise
|
|
192
|
+
except Exception:
|
|
193
|
+
_LOGGER.debug(
|
|
194
|
+
"%s: delayed LAN login retry failed",
|
|
195
|
+
getattr(self, "device_id", "?"), exc_info=True,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
self._login_task = asyncio.create_task(_retry())
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "0.17.
|
|
7
|
+
version = "0.17.2"
|
|
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.17.1 → python_aidot_cameras-0.17.2/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.17.
|
|
3
|
+
Version: 0.17.2
|
|
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
|
|
@@ -178,6 +178,9 @@ audio, idle release, the sprop cache path) are documented in
|
|
|
178
178
|
| `AIDOT_DTLS_FAST_LIVEPLAY` | The DTLS (A000088) analogue: skip the `livePlayReq`-echo and `livePlayResp` waits (the dominant LAN cold-start cost) while keeping the full ICE/TURN/DTLS handshake, so remote/relay viewing is unaffected. **On by default**; set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
|
|
179
179
|
| `AIDOT_PERSISTENT_MQTT` | Reuse ONE account-level persistent MQTT connection for commands, attribute fetches, and stream-open signaling (matching the official app) instead of connecting per operation. **On by default** (live soaks cut SDES NO_MEDIA from ~57% to ~11-19%); set to `0`/`false`/`no`/`off` to disable. | enabled (on) |
|
|
180
180
|
| `AIDOT_SERVE_RELAY` | Hold the public stream port via an internal relay that proxies to ffmpeg, so the first (cold) view connects instead of failing while ffmpeg can't pre-bind the port. Set to `0` to serve ffmpeg directly. | `1` (enabled) |
|
|
181
|
+
| `AIDOT_LOGIN_RETRY_LIMIT` | Consecutive failed LAN logins after which a device is left alone (it is retried again the next time something asks for it). Applies to every device, camera or not - the devices that hit this are lights. | `6` |
|
|
182
|
+
| `AIDOT_LOGIN_RETRY_CAP_S` | Ceiling on the exponential delay between those retries. | `60` |
|
|
183
|
+
| `AIDOT_LOGIN_CONNECT_TIMEOUT_S` | Ceiling on one LAN connect+login attempt. A device that completes the TCP handshake and then stops answering is abandoned and its socket closed rather than parking the attempt forever. | `20` |
|
|
181
184
|
| `AIDOT_LIVESTREAM_PARAM` | **No-op (ignored).** Formerly requested the cloud `liveStreamParam` pre-connect for battery cameras. That call provisions the camera toward AWS KVS, so it sends its media there instead of to this library and the live view negotiates and then shows nothing - and battery cameras were the only ones it applied to. Setting it (or `start_keepalive(live_stream_param=True)`) logs one warning and changes nothing. | ignored |
|
|
182
185
|
|
|
183
186
|
### Security hardening
|
|
@@ -14,6 +14,7 @@ aidot_cameras/device_client.py
|
|
|
14
14
|
aidot_cameras/discover.py
|
|
15
15
|
aidot_cameras/exceptions.py
|
|
16
16
|
aidot_cameras/g711.py
|
|
17
|
+
aidot_cameras/lan_retry.py
|
|
17
18
|
aidot_cameras/py.typed
|
|
18
19
|
aidot_cameras/_vendor/__init__.py
|
|
19
20
|
aidot_cameras/_vendor/aiortc/__init__.py
|
|
@@ -113,10 +114,13 @@ tests/test_keepalive_renew.py
|
|
|
113
114
|
tests/test_key_restart_sdp.py
|
|
114
115
|
tests/test_keyframe_prompter.py
|
|
115
116
|
tests/test_lan_control.py
|
|
117
|
+
tests/test_lan_retry_reaches_the_storming_devices.py
|
|
116
118
|
tests/test_light_active_color_mode.py
|
|
117
119
|
tests/test_live_stream_param.py
|
|
118
120
|
tests/test_live_validate_policy.py
|
|
119
121
|
tests/test_logging_caps.py
|
|
122
|
+
tests/test_login_connect_deadline.py
|
|
123
|
+
tests/test_login_reconnect_backoff.py
|
|
120
124
|
tests/test_media_socket_rcvbuf.py
|
|
121
125
|
tests/test_media_wait_floor.py
|
|
122
126
|
tests/test_motion_poll.py
|
{python_aidot_cameras-0.17.1 → python_aidot_cameras-0.17.2}/tests/test_dormant_camera_cleanup.py
RENAMED
|
@@ -63,3 +63,55 @@ def test_real_failures_still_warn():
|
|
|
63
63
|
assert _classify_ffmpeg_exit(-9, teardown_requested=False) == logging.WARNING
|
|
64
64
|
# ...but an expected teardown is quiet.
|
|
65
65
|
assert _classify_ffmpeg_exit(-9, teardown_requested=True) == logging.DEBUG
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_every_exit_that_stops_streaming_also_deregisters():
|
|
69
|
+
"""Enumerate the exits instead of slicing to one of them.
|
|
70
|
+
|
|
71
|
+
The tests above slice from `if _idle_release:` to the end of the source, so
|
|
72
|
+
they can only ever see that one exit. A second exit was added later - the
|
|
73
|
+
futile-keepalive abandon - and it returned without deregistering, leaving a
|
|
74
|
+
dormant camera's stream pointed at a dead serve port. No test could see it,
|
|
75
|
+
because no test looked anywhere else.
|
|
76
|
+
|
|
77
|
+
This walks the function instead: every `return` that follows
|
|
78
|
+
`self._streaming_active = False` must have a `_deregister_go2rtc` call
|
|
79
|
+
between the two. It fails on the next exit added carelessly, which the
|
|
80
|
+
slicing form cannot.
|
|
81
|
+
"""
|
|
82
|
+
import ast
|
|
83
|
+
import textwrap
|
|
84
|
+
|
|
85
|
+
for name in ("_sdes_keepalive_loop_inner", "_dtls_serve_loop_inner"):
|
|
86
|
+
tree = ast.parse(textwrap.dedent(_src(name)))
|
|
87
|
+
fn = tree.body[0]
|
|
88
|
+
|
|
89
|
+
stops = [] # (lineno of `_streaming_active = False`)
|
|
90
|
+
for node in ast.walk(fn):
|
|
91
|
+
if not isinstance(node, ast.Assign):
|
|
92
|
+
continue
|
|
93
|
+
for t in node.targets:
|
|
94
|
+
if (isinstance(t, ast.Attribute) and t.attr == "_streaming_active"
|
|
95
|
+
and isinstance(node.value, ast.Constant)
|
|
96
|
+
and node.value.value is False):
|
|
97
|
+
stops.append(node.lineno)
|
|
98
|
+
|
|
99
|
+
assert stops, f"{name}: no `_streaming_active = False` found at all"
|
|
100
|
+
|
|
101
|
+
deregs = [n.lineno for n in ast.walk(fn)
|
|
102
|
+
if isinstance(n, ast.Attribute) and n.attr == "_deregister_go2rtc"]
|
|
103
|
+
returns = [n.lineno for n in ast.walk(fn) if isinstance(n, ast.Return)]
|
|
104
|
+
|
|
105
|
+
for stop in stops:
|
|
106
|
+
# The return that this stop falls through to.
|
|
107
|
+
after = [r for r in returns if r > stop]
|
|
108
|
+
if not after:
|
|
109
|
+
continue # falls through to the loop, not an exit
|
|
110
|
+
exit_line = min(after)
|
|
111
|
+
between = [d for d in deregs if stop < d < exit_line]
|
|
112
|
+
assert between, (
|
|
113
|
+
f"{name}: the exit at line {exit_line} sets _streaming_active="
|
|
114
|
+
f"False (line {stop}) and returns without calling "
|
|
115
|
+
f"_deregister_go2rtc. A dormant camera's go2rtc stream is left "
|
|
116
|
+
f"pointing at a serve port with nothing listening."
|
|
117
|
+
)
|