python-aidot-cameras 1.0.0b4__tar.gz → 1.0.0b5__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-1.0.0b4/python_aidot_cameras.egg-info → python_aidot_cameras-1.0.0b5}/PKG-INFO +1 -1
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/client.py +2 -1
- python_aidot_cameras-1.0.0b5/aidot_cameras/camera/sd_events.py +305 -0
- python_aidot_cameras-1.0.0b5/aidot_cameras/camera/sd_listing.py +142 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/pyproject.toml +1 -1
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/python_aidot_cameras.egg-info/SOURCES.txt +2 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sd_event_decode.py +38 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sd_event_probe_payloads.py +28 -7
- python_aidot_cameras-1.0.0b5/tests/test_sd_listing.py +192 -0
- python_aidot_cameras-1.0.0b4/aidot_cameras/camera/sd_events.py +0 -134
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/LICENSE +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/MANIFEST.in +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/README.md +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_upstream.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/ble_gateway.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/constants.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/hwaccel.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/protocol.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/sdes.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/sdes_open.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/webrtc_open.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/client.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/device_client.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/lan_retry.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/setup.cfg +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_answer_sdp_video_pt.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_audio_mux_offline.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_avio_request.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_avio_response_parse.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_avio_response_router.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_battery_detection.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_battery_evidence_corroboration.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_battery_skip_turn.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_bitrate_campaign_arms.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_ble_gateway.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_bridge_state_sentinels.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_busy_backoff.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_cloud_count.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_cloud_plan.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_constants_imports_resolve.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_control_verdicts.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_device_cmd_seq_match.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_dormant_camera_cleanup.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_mux_terminal_write.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_resolution_ack.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_dtls_stdout_producer.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_env_seams.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_feature_probe_needs_a_live_session.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_futile_keepalive.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_gate_requires_a_decoded_frame.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_hwaccel_probe.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_ice_peer_reflexive.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_lan_retry_reaches_the_storming_devices.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_live_session_carries_talk.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_live_validate_policy.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_login_connect_deadline.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_login_reconnect_backoff.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_media_counted_only_when_plaintext.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_media_socket_rcvbuf.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_media_wait_floor.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_no_key_material_in_logs.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_offer_keeps_rtcp_feedback.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_peerid_reuse.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_ptz_probe_reads_what_it_is_told.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_quality_campaign_in_session.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_recent_recordings.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_remb.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_resolution_persists.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_restart_cycle.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_scripts_importable.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sctp_init_ack_tsn.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_avio_dispatch.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_first_media_stall_report.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_offer_video_order.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_offer_video_pin.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_sctp_abort.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_serve_stderr_drain.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_terminal_ack.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_terminal_ack_latency.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdp_narrowing_properties.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sdp_payload_type_narrowing.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_self_peer_is_an_address_not_an_ip.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_serve_loss_tolerance.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_shared_house_seam.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_snapshot_cancellation.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_speak.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sprop_capture_gate.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sprop_staleness.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sprop_write_is_reported_truthfully.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_srtp_rx_rekey.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_srtp_tx_key_note.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_stall_report_says_what_the_answer_carried.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_stall_report_says_whether_the_trigger_was_answered.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_stall_report_separates_silence_from_undecryptable.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_stall_report_survives_a_caller_giving_up.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_stall_reports_reach_the_artifact.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_talk.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_talk_reports_a_speaker_that_never_opened.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_turn_create_permission.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_tutk_path_is_out_of_reach.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_video_profile_log.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_viewer_present_push_mode.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_wait_or_event.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_wake_gate_camera_evidence.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_wake_readiness_retry.py +0 -0
- {python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_webrtc_answer_race.py +0 -0
{python_aidot_cameras-1.0.0b4/python_aidot_cameras.egg-info → python_aidot_cameras-1.0.0b5}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0b5
|
|
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-1.0.0b4 → python_aidot_cameras-1.0.0b5}/aidot_cameras/camera/client.py
RENAMED
|
@@ -46,6 +46,7 @@ from .playback import ( # re-exported (split into playback.py)
|
|
|
46
46
|
from .webrtc import WebRTCSession # re-exported (split into webrtc.py)
|
|
47
47
|
from .sdes import SdesSession # noqa: F401 - re-exported (split into sdes.py); used by sdes_open mixin
|
|
48
48
|
from .controls import _CameraControlsMixin
|
|
49
|
+
from .sd_listing import _CameraSdMixin
|
|
49
50
|
from .webrtc_open import _WebRTCOpenMixin
|
|
50
51
|
from .sdes_open import _SdesOpenMixin
|
|
51
52
|
from .protocol import ( # noqa: F401 - used here and/or by the webrtc_open mixin; kept re-exported for back-compat
|
|
@@ -859,7 +860,7 @@ def _ack_matches_seq(msg, seq):
|
|
|
859
860
|
return code if isinstance(code, int) else False
|
|
860
861
|
|
|
861
862
|
|
|
862
|
-
class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
863
|
+
class CameraMixin(_CameraControlsMixin, _CameraSdMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
863
864
|
"""All camera/streaming methods, mixed into DeviceClient via inheritance."""
|
|
864
865
|
|
|
865
866
|
# Devices without an aesKey never get this set by the core constructor;
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Build the camera's recording-list requests, and decode its replies.
|
|
2
|
+
|
|
3
|
+
Both halves live here because they are one wire format, and because the
|
|
4
|
+
selector lesson below belongs next to the code that sends it: the requests used
|
|
5
|
+
to exist only in ``scripts/sd_event_probe.py``, which the package cannot import,
|
|
6
|
+
so every caller either duplicated them or could not ask at all.
|
|
7
|
+
|
|
8
|
+
The decoder was written from the vendor client's own decoder rather than from a
|
|
9
|
+
capture. What is CONFIRMED from ``KVSWebRTCChannel``'s 0x319 handler:
|
|
10
|
+
|
|
11
|
+
* the reply is matched by the SEQUENCE NUMBER of the request, not by the
|
|
12
|
+
command alone - the handler's first act is ``if getListSeq != seq: return``;
|
|
13
|
+
* a four-byte little-endian value is assembled and named ``listEventTotalTime``
|
|
14
|
+
(its name says time, not count, and this module does not pretend otherwise);
|
|
15
|
+
* a single byte is read as ``listEventEndFlag`` - the reply is PAGED, and a
|
|
16
|
+
caller that stops at the first packet gets a truncated list;
|
|
17
|
+
* a single byte is read as ``listEventCount`` - how many records this packet
|
|
18
|
+
carries;
|
|
19
|
+
* records are **twelve bytes each**, copied out with an explicit
|
|
20
|
+
``arraycopy(..., 12)``, and each begins with a two-byte year read via
|
|
21
|
+
``byteToShort`` - an ``STimeDay``.
|
|
22
|
+
|
|
23
|
+
**The header is now measured, not inferred (2026-08-11, run 31497241870).** The
|
|
24
|
+
first version of this module used the published TUTK layout - two STimeDays then
|
|
25
|
+
the counters, 24 bytes - and refused to decode the real replies because they
|
|
26
|
+
were shorter than that. Refusing was correct, and it is how the true layout came
|
|
27
|
+
back readable instead of being forced into the wrong one.
|
|
28
|
+
|
|
29
|
+
Two live replies, and a 12-byte header fits both exactly:
|
|
30
|
+
|
|
31
|
+
HASLISTEVENT_RESP 00000000 01000000 0001a800 + 168 bytes
|
|
32
|
+
LISTEVENT_RESP 00000000 01000000 00010000 + 0 bytes
|
|
33
|
+
|
|
34
|
+
channel uint32 LE total uint32 LE index end_flag count reserved
|
|
35
|
+
|
|
36
|
+
`count` equals the body length in both - 168 and 0. And 168 is exactly the
|
|
37
|
+
7-day range that was requested, one byte per hour, which is what HASLISTEVENT
|
|
38
|
+
means: a per-hour occupancy map rather than a list. LISTEVENT carries 12-byte
|
|
39
|
+
records in the same body.
|
|
40
|
+
"""
|
|
41
|
+
import struct
|
|
42
|
+
import time
|
|
43
|
+
from typing import List, NamedTuple, Optional
|
|
44
|
+
|
|
45
|
+
#: One record. The size is confirmed from the vendor's own arraycopy.
|
|
46
|
+
EVENT_RECORD_LEN = 12
|
|
47
|
+
|
|
48
|
+
#: Measured from two live replies, not taken from the published struct - the
|
|
49
|
+
#: published one is 24 bytes and neither reply is that long.
|
|
50
|
+
_HEADER_LEN = 12
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SdEvent(NamedTuple):
|
|
54
|
+
"""One recording the camera says it holds."""
|
|
55
|
+
|
|
56
|
+
#: UTC, from the record's STimeDay.
|
|
57
|
+
year: int
|
|
58
|
+
month: int
|
|
59
|
+
day: int
|
|
60
|
+
hour: int
|
|
61
|
+
minute: int
|
|
62
|
+
second: int
|
|
63
|
+
channel: int
|
|
64
|
+
event: int
|
|
65
|
+
status: int
|
|
66
|
+
|
|
67
|
+
def isoformat(self) -> str:
|
|
68
|
+
return (f"{self.year:04d}-{self.month:02d}-{self.day:02d}"
|
|
69
|
+
f"T{self.hour:02d}:{self.minute:02d}:{self.second:02d}Z")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SdEventPage(NamedTuple):
|
|
73
|
+
"""One packet of the paged reply.
|
|
74
|
+
|
|
75
|
+
``end_flag`` is why this is a page and not a list: the camera answers a
|
|
76
|
+
range in several packets and a caller that reads only the first silently
|
|
77
|
+
truncates the recording list. Nothing here hides that.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
events: List[SdEvent]
|
|
81
|
+
channel: int
|
|
82
|
+
total: int
|
|
83
|
+
#: Named page_index / record_count, not index / count: a NamedTuple field
|
|
84
|
+
#: called `index` or `count` shadows tuple.index() and tuple.count(), so
|
|
85
|
+
#: `page.count` would stop being callable and read as a number instead.
|
|
86
|
+
page_index: int
|
|
87
|
+
end_flag: int
|
|
88
|
+
record_count: int
|
|
89
|
+
#: True when the payload length matches the count the header declared.
|
|
90
|
+
consistent: bool
|
|
91
|
+
#: Bytes left over after the declared records - non-zero means the layout
|
|
92
|
+
#: read here is wrong, and says so rather than dropping them.
|
|
93
|
+
trailing: int
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SdRecordingList(NamedTuple):
|
|
97
|
+
"""What one camera says its card holds over one window.
|
|
98
|
+
|
|
99
|
+
``records`` is the list and is always the primary answer. ``hours`` is the
|
|
100
|
+
HASLISTEVENT occupancy map when the camera gave one - optional, never
|
|
101
|
+
load-bearing. Measured 2026-08-11: the camera that returned four real
|
|
102
|
+
records returned an ALL-ZERO map for the same window, so a consumer that
|
|
103
|
+
trusted the map over the records would have shown an empty card.
|
|
104
|
+
|
|
105
|
+
``start_ts``/``end_ts`` are the window that was asked for. The map is one
|
|
106
|
+
byte per hour counting from ``start_ts``, so without them a byte cannot be
|
|
107
|
+
placed on a clock.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
records: List[SdEvent]
|
|
111
|
+
hours: Optional[bytes]
|
|
112
|
+
#: True when the camera replied to at least one of the two requests.
|
|
113
|
+
#:
|
|
114
|
+
#: Without this, a camera that says NOTHING and a camera that says "my card
|
|
115
|
+
#: is empty" both arrive as an empty list, and a browser has no way to tell
|
|
116
|
+
#: "we asked and got silence" from "there is nothing there" - so it prints
|
|
117
|
+
#: the second when the first is true. That collapse is the exact failure
|
|
118
|
+
#: this subsystem exists to prevent, and it is worth a field rather than a
|
|
119
|
+
#: convention: an empty ``records`` is only a statement about the card when
|
|
120
|
+
#: ``answered`` is True.
|
|
121
|
+
answered: bool
|
|
122
|
+
#: Whether the list that came back is the whole list. False when the
|
|
123
|
+
#: reply's end flag never arrived, and False when a reply arrived that could
|
|
124
|
+
#: not be decoded - in both cases there may be more than is shown.
|
|
125
|
+
#:
|
|
126
|
+
#: Only meaningful when ``answered`` is True. Silence carries no end flag,
|
|
127
|
+
#: so it says nothing about completeness either way.
|
|
128
|
+
complete: bool
|
|
129
|
+
start_ts: float
|
|
130
|
+
end_ts: float
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
#: Reply command ids, so a caller that knows which question it asked can say so.
|
|
134
|
+
LISTEVENT_RESP_CMD = 0x319
|
|
135
|
+
HASLISTEVENT_RESP_CMD = 0x4B6
|
|
136
|
+
|
|
137
|
+
#: Request command ids, from AVIOCTRLDEFs in the vendor's own client.
|
|
138
|
+
HASLISTEVENT_REQ_CMD = 0x4B5
|
|
139
|
+
LISTEVENT_REQ_CMD = 0x318
|
|
140
|
+
|
|
141
|
+
#: The event selector the vendor's app sends, read off
|
|
142
|
+
#: KVSWebRTCChannel.getSDRecordList: `const/16 v1, 0x12`.
|
|
143
|
+
#:
|
|
144
|
+
#: It is NOT the default here, because on a camera with a card it returns
|
|
145
|
+
#: nothing. Measured 2026-08-11: the same session, the same window, 0x12
|
|
146
|
+
#: answered an EMPTY page while 0 answered with four real records, the first
|
|
147
|
+
#: stamped 2026-08-11T20:41:42Z. The earlier note claiming the opposite was
|
|
148
|
+
#: measured on three cameras with no card, where every selector answered empty
|
|
149
|
+
#: and the comparison was between two empty answers.
|
|
150
|
+
SD_EVENT_APP = 0x12
|
|
151
|
+
|
|
152
|
+
#: The selector that returns records. The default, so a caller that does not
|
|
153
|
+
#: know about the above cannot accidentally ask the question that gets silence.
|
|
154
|
+
SD_EVENT_ANY = 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def stimeday(when: float) -> bytes:
|
|
158
|
+
"""The vendor's 8-byte STimeDay, from ``AVIOCTRLDEFs$STimeDay``.
|
|
159
|
+
|
|
160
|
+
``unsigned short year`` then six single bytes - month, day, wday, hour,
|
|
161
|
+
minute, second. UTC, because the field is named ``startutctime``.
|
|
162
|
+
"""
|
|
163
|
+
t = time.gmtime(when)
|
|
164
|
+
return struct.pack(
|
|
165
|
+
"<HBBBBBB",
|
|
166
|
+
t.tm_year,
|
|
167
|
+
t.tm_mon,
|
|
168
|
+
t.tm_mday,
|
|
169
|
+
(t.tm_wday + 1) % 7, # tm_wday is Mon=0; the vendor's wday is Sun=0
|
|
170
|
+
t.tm_hour,
|
|
171
|
+
t.tm_min,
|
|
172
|
+
t.tm_sec,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def haslistevent_payload(start: float, end: float, channel: int = 0,
|
|
177
|
+
event: int = SD_EVENT_ANY) -> bytes:
|
|
178
|
+
"""22 bytes: channel, start, end, and a two-byte selector tail.
|
|
179
|
+
|
|
180
|
+
The length is not a guess - ``parseConent`` allocates ``const/16 v1, 0x16``,
|
|
181
|
+
which is 22, and copies the channel at offset 0 and the first STimeDay at
|
|
182
|
+
offset 4.
|
|
183
|
+
"""
|
|
184
|
+
return (struct.pack("<I", channel)
|
|
185
|
+
+ stimeday(start) + stimeday(end)
|
|
186
|
+
+ struct.pack("<H", event & 0xFFFF))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def listevent_payload(start: float, end: float, channel: int = 0,
|
|
190
|
+
event: int = SD_EVENT_ANY, status: int = 0) -> bytes:
|
|
191
|
+
"""24 bytes, not 22 - and the difference is the whole point.
|
|
192
|
+
|
|
193
|
+
``SMsgAVIoctrlListEventReq`` has three ``parseConent`` overloads. The one
|
|
194
|
+
whose field list this was first written from is not the one the WebRTC path
|
|
195
|
+
uses: ``KVSWebRTCChannel.getSDRecordList`` calls the epoch-long overload,
|
|
196
|
+
which allocates ``const/16 v4, 0x18`` - 24 bytes - where the other
|
|
197
|
+
allocates 22. Four bytes of channel, two eight-byte STimeDays, the event and
|
|
198
|
+
status selectors, then two bytes of tail.
|
|
199
|
+
"""
|
|
200
|
+
return (struct.pack("<I", channel)
|
|
201
|
+
+ stimeday(start) + stimeday(end)
|
|
202
|
+
+ bytes((event & 0xFF, status & 0xFF))
|
|
203
|
+
+ b"\x00\x00")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _consistent(count: int, body_len: int, command: Optional[int]) -> bool:
|
|
207
|
+
"""Does the declared entry count match the body, for THIS reply's entry size?
|
|
208
|
+
|
|
209
|
+
Records are 12 bytes each; occupancy-map hours are one byte each. When the
|
|
210
|
+
command is known the right rule is used. When it is not, either reading is
|
|
211
|
+
accepted - reporting False for a well-formed page just because the decoder
|
|
212
|
+
was not told what it was reading would be the check lying about the data.
|
|
213
|
+
"""
|
|
214
|
+
if command == HASLISTEVENT_RESP_CMD:
|
|
215
|
+
return count == body_len
|
|
216
|
+
if command == LISTEVENT_RESP_CMD:
|
|
217
|
+
return count * EVENT_RECORD_LEN == body_len
|
|
218
|
+
return count == body_len or count * EVENT_RECORD_LEN == body_len
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _stimeday(buf: bytes, off: int) -> tuple:
|
|
222
|
+
year, month, day, _wday, hour, minute, second = struct.unpack_from(
|
|
223
|
+
"<HBBBBBB", buf, off)
|
|
224
|
+
return year, month, day, hour, minute, second
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def decode_event_record(buf: bytes, off: int = 0) -> SdEvent:
|
|
228
|
+
"""One 12-byte record: an STimeDay then channel, event, status, reserved."""
|
|
229
|
+
year, month, day, hour, minute, second = _stimeday(buf, off)
|
|
230
|
+
channel, event, status = struct.unpack_from("<BBB", buf, off + 8)
|
|
231
|
+
return SdEvent(year, month, day, hour, minute, second,
|
|
232
|
+
channel, event, status)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def decode_list_event_response(payload: bytes, *, command: Optional[int] = None) -> Optional[SdEventPage]:
|
|
236
|
+
"""Decode a 0x319 payload, or None if it cannot be one.
|
|
237
|
+
|
|
238
|
+
None rather than an exception or a half-filled result: this runs against a
|
|
239
|
+
reply nobody has seen yet, and "this does not fit" is the answer that keeps
|
|
240
|
+
the next person honest.
|
|
241
|
+
"""
|
|
242
|
+
# A HASLISTEVENT body is a per-hour occupancy map, not records. Reading it
|
|
243
|
+
# as records is not a near miss: the real 168-byte map is exactly fourteen
|
|
244
|
+
# 12-byte records, so the decode SUCCEEDS and invents fourteen recordings
|
|
245
|
+
# dated 0000-00-00. Measured 2026-08-11, when a formatted-but-empty card's
|
|
246
|
+
# 24-byte map read as "records: 2". A caller that knows which reply it has
|
|
247
|
+
# gets nothing rather than fiction; a caller that passes no command still
|
|
248
|
+
# gets the old best-effort behaviour, because that is what the existing
|
|
249
|
+
# tests and probe runs read.
|
|
250
|
+
if command == HASLISTEVENT_RESP_CMD:
|
|
251
|
+
return None
|
|
252
|
+
if payload is None or len(payload) < _HEADER_LEN:
|
|
253
|
+
return None
|
|
254
|
+
channel, total = struct.unpack_from("<II", payload, 0)
|
|
255
|
+
index, end_flag, count, _reserved = struct.unpack_from("<BBBB", payload, 8)
|
|
256
|
+
body = payload[_HEADER_LEN:]
|
|
257
|
+
usable = len(body) // EVENT_RECORD_LEN
|
|
258
|
+
# `count` is a BYTE count on HASLISTEVENT (one byte per hour) and a RECORD
|
|
259
|
+
# count on LISTEVENT. Only the record reading is meaningful here, so the
|
|
260
|
+
# number of records decoded is bounded by what the body can actually hold
|
|
261
|
+
# rather than by what the header claims.
|
|
262
|
+
take = usable
|
|
263
|
+
events = [decode_event_record(body, i * EVENT_RECORD_LEN)
|
|
264
|
+
for i in range(take)]
|
|
265
|
+
return SdEventPage(
|
|
266
|
+
events=events,
|
|
267
|
+
channel=channel,
|
|
268
|
+
total=total,
|
|
269
|
+
page_index=index,
|
|
270
|
+
end_flag=end_flag,
|
|
271
|
+
record_count=count,
|
|
272
|
+
# `count` counts ENTRIES in the body, and an entry is not always a
|
|
273
|
+
# byte. Measured 2026-08-11 against the first live replies carrying real
|
|
274
|
+
# data: a LISTEVENT_RESP with four records is 12 header + 48 body and
|
|
275
|
+
# declares count=4, while a HASLISTEVENT_RESP occupancy map declares
|
|
276
|
+
# count=168 for 168 bytes because there an entry IS one hour-byte.
|
|
277
|
+
#
|
|
278
|
+
# So the entry size depends on WHICH reply this is, and the payload
|
|
279
|
+
# alone cannot always say - a 24-byte body with count=24 is a valid
|
|
280
|
+
# 24-hour map, and a 24-byte body with count=2 is a valid two-record
|
|
281
|
+
# page. Pass `command` when it is known. Without it, accept either
|
|
282
|
+
# reading rather than calling a good page inconsistent, and say in
|
|
283
|
+
# `consistent` only what can honestly be said.
|
|
284
|
+
consistent=_consistent(count, len(body), command),
|
|
285
|
+
trailing=len(body) - usable * EVENT_RECORD_LEN,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def decode_hour_map(payload: Optional[bytes], *,
|
|
290
|
+
command: Optional[int] = None) -> Optional[bytes]:
|
|
291
|
+
"""The per-hour occupancy bytes from a HASLISTEVENT reply, or None.
|
|
292
|
+
|
|
293
|
+
One byte per hour of the window that was requested, starting at the request
|
|
294
|
+
start: 168 bytes for seven days, 24 for one. A non-zero byte means the card
|
|
295
|
+
holds something recorded in that hour. It says nothing about how many
|
|
296
|
+
recordings, so nothing here counts them.
|
|
297
|
+
|
|
298
|
+
None means "this is not a map", which is a different answer from an empty
|
|
299
|
+
map and is why the return is not simply ``b""``.
|
|
300
|
+
"""
|
|
301
|
+
if command == LISTEVENT_RESP_CMD:
|
|
302
|
+
return None
|
|
303
|
+
if payload is None or len(payload) <= _HEADER_LEN:
|
|
304
|
+
return None
|
|
305
|
+
return bytes(payload[_HEADER_LEN:])
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Ask a camera what its own SD card holds.
|
|
2
|
+
|
|
3
|
+
Stateless by design: this asks a session it is handed and returns what came
|
|
4
|
+
back. It never opens a session, never caches and never decides that listing is
|
|
5
|
+
worth waking a camera for - those are policy, and policy belongs to the caller
|
|
6
|
+
that knows what a session costs. A cloud listing is one ~200 ms request; this
|
|
7
|
+
needs a WebRTC session, which is 15-21 s on DTLS and 25-70 s cold on SDES.
|
|
8
|
+
|
|
9
|
+
Nothing here raises at the caller. A camera with no card, a firmware that
|
|
10
|
+
implements a command without answering it, and a channel that died mid-request
|
|
11
|
+
are all ordinary, and each of them has to arrive in a media browser as an empty
|
|
12
|
+
list rather than as a traceback.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
from .sd_events import (
|
|
20
|
+
HASLISTEVENT_REQ_CMD,
|
|
21
|
+
HASLISTEVENT_RESP_CMD,
|
|
22
|
+
LISTEVENT_REQ_CMD,
|
|
23
|
+
LISTEVENT_RESP_CMD,
|
|
24
|
+
SdRecordingList,
|
|
25
|
+
decode_hour_map,
|
|
26
|
+
decode_list_event_response,
|
|
27
|
+
haslistevent_payload,
|
|
28
|
+
listevent_payload,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_LOGGER = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
#: Long enough for a camera that answers slowly, short enough that two silent
|
|
34
|
+
#: requests cannot hold a session for more than a viewer would tolerate.
|
|
35
|
+
_SD_LIST_TIMEOUT_S = 8.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _CameraSdMixin:
|
|
39
|
+
"""The on-device recording query, mixed into CameraMixin."""
|
|
40
|
+
|
|
41
|
+
async def _sd_ask(self, session: Any, cmd: int, payload: bytes,
|
|
42
|
+
response_cmd: int, timeout: float) -> Optional[Any]:
|
|
43
|
+
"""One request, with every failure flattened to None."""
|
|
44
|
+
try:
|
|
45
|
+
return await session.async_avio_request(
|
|
46
|
+
cmd, payload, response_cmd=response_cmd, timeout=timeout)
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
_LOGGER.debug("SD request %#x failed for %s: %s",
|
|
49
|
+
cmd, getattr(self, "device_id", "?"), exc)
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
async def async_get_sd_recordings(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
session: Any = None,
|
|
56
|
+
days: int = 7,
|
|
57
|
+
channel: int = 0,
|
|
58
|
+
timeout: float = _SD_LIST_TIMEOUT_S,
|
|
59
|
+
) -> Optional[SdRecordingList]:
|
|
60
|
+
"""What the card holds over the last ``days``, or None if we could not ask.
|
|
61
|
+
|
|
62
|
+
**Three outcomes, not two, and a caller has to be able to tell them
|
|
63
|
+
apart.** A browser that collapses any two of them shows a user the same
|
|
64
|
+
empty folder for opposite reasons, which is the failure this whole
|
|
65
|
+
subsystem was built to stop:
|
|
66
|
+
|
|
67
|
+
* ``None`` - there was no session to ask through. Nothing was sent.
|
|
68
|
+
* ``answered=False`` - the requests went out and the camera said
|
|
69
|
+
nothing at all. This is what a model that does not implement the
|
|
70
|
+
commands looks like, and also what a channel that has just died
|
|
71
|
+
looks like; it is NOT a statement about the card.
|
|
72
|
+
* ``answered=True`` - the camera replied. Only now does an empty
|
|
73
|
+
``records`` mean the card holds nothing in this window.
|
|
74
|
+
|
|
75
|
+
Asks with event selector 0. The vendor's app sends 0x12 and on a
|
|
76
|
+
card-bearing A000088 that returns an EMPTY page while 0 returns the
|
|
77
|
+
records - measured 2026-08-11, same session, same window.
|
|
78
|
+
|
|
79
|
+
One request each for the list and the occupancy map. The reply is paged
|
|
80
|
+
and there is no known continuation request: the vendor's handler matches
|
|
81
|
+
a reply to the sequence number of the single request it sent. So a reply
|
|
82
|
+
whose end flag is not set comes back with ``complete=False`` rather than
|
|
83
|
+
with a second request this code would have to invent.
|
|
84
|
+
"""
|
|
85
|
+
session = session if session is not None else getattr(
|
|
86
|
+
self, "_stream_session", None)
|
|
87
|
+
if session is None:
|
|
88
|
+
return None
|
|
89
|
+
# A cheap early-out for a session already known to be torn down: there
|
|
90
|
+
# is no point spending two timeouts on it. Only SDES sessions publish
|
|
91
|
+
# `is_alive`, so this catches some dead sessions and not others - which
|
|
92
|
+
# is fine, because it is NOT what keeps a dead session from being
|
|
93
|
+
# reported as an empty card. `answered` does that, on every transport.
|
|
94
|
+
if getattr(session, "is_alive", True) is False:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
end_ts = time.time()
|
|
98
|
+
start_ts = end_ts - days * 86400
|
|
99
|
+
|
|
100
|
+
records = []
|
|
101
|
+
complete = True
|
|
102
|
+
list_reply = await self._sd_ask(
|
|
103
|
+
session, LISTEVENT_REQ_CMD,
|
|
104
|
+
listevent_payload(start_ts, end_ts, channel=channel),
|
|
105
|
+
LISTEVENT_RESP_CMD, timeout)
|
|
106
|
+
if list_reply is not None:
|
|
107
|
+
page = decode_list_event_response(
|
|
108
|
+
bytes(list_reply.payload or b""), command=LISTEVENT_RESP_CMD)
|
|
109
|
+
if page is None:
|
|
110
|
+
# The decode is measured from a handful of live replies and a
|
|
111
|
+
# reply with a different shape is entirely possible. It is an
|
|
112
|
+
# empty list with a logged reason, never an exception - but not
|
|
113
|
+
# a COMPLETE empty list: something came back and could not be
|
|
114
|
+
# read, so there may well be recordings behind it.
|
|
115
|
+
complete = False
|
|
116
|
+
_LOGGER.debug(
|
|
117
|
+
"SD list reply for %s did not decode (%d bytes)",
|
|
118
|
+
getattr(self, "device_id", "?"),
|
|
119
|
+
len(bytes(list_reply.payload or b"")))
|
|
120
|
+
else:
|
|
121
|
+
records = page.events
|
|
122
|
+
complete = page.end_flag == 1
|
|
123
|
+
|
|
124
|
+
hours = None
|
|
125
|
+
map_reply = await self._sd_ask(
|
|
126
|
+
session, HASLISTEVENT_REQ_CMD,
|
|
127
|
+
haslistevent_payload(start_ts, end_ts, channel=channel),
|
|
128
|
+
HASLISTEVENT_RESP_CMD, timeout)
|
|
129
|
+
if map_reply is not None:
|
|
130
|
+
hours = decode_hour_map(
|
|
131
|
+
bytes(map_reply.payload or b""), command=HASLISTEVENT_RESP_CMD)
|
|
132
|
+
|
|
133
|
+
return SdRecordingList(
|
|
134
|
+
records=records,
|
|
135
|
+
hours=hours,
|
|
136
|
+
# Either reply is enough to prove the camera is listening. Both
|
|
137
|
+
# silent is the case that must never read as an empty card.
|
|
138
|
+
answered=list_reply is not None or map_reply is not None,
|
|
139
|
+
complete=complete,
|
|
140
|
+
start_ts=start_ts,
|
|
141
|
+
end_ts=end_ts,
|
|
142
|
+
)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-aidot-cameras"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.0b5"
|
|
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-1.0.0b4 → python_aidot_cameras-1.0.0b5/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: 1.0.
|
|
3
|
+
Version: 1.0.0b5
|
|
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
|
|
@@ -60,6 +60,7 @@ aidot_cameras/camera/models.py
|
|
|
60
60
|
aidot_cameras/camera/playback.py
|
|
61
61
|
aidot_cameras/camera/protocol.py
|
|
62
62
|
aidot_cameras/camera/sd_events.py
|
|
63
|
+
aidot_cameras/camera/sd_listing.py
|
|
63
64
|
aidot_cameras/camera/sdes.py
|
|
64
65
|
aidot_cameras/camera/sdes_open.py
|
|
65
66
|
aidot_cameras/camera/tutk.py
|
|
@@ -161,6 +162,7 @@ tests/test_scripts_importable.py
|
|
|
161
162
|
tests/test_sctp_init_ack_tsn.py
|
|
162
163
|
tests/test_sd_event_decode.py
|
|
163
164
|
tests/test_sd_event_probe_payloads.py
|
|
165
|
+
tests/test_sd_listing.py
|
|
164
166
|
tests/test_sdes_adaptive.py
|
|
165
167
|
tests/test_sdes_avio_dispatch.py
|
|
166
168
|
tests/test_sdes_bridge_break_gate.py
|
|
@@ -22,7 +22,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
22
22
|
|
|
23
23
|
from aidot_cameras.camera.sd_events import (
|
|
24
24
|
EVENT_RECORD_LEN,
|
|
25
|
+
HASLISTEVENT_RESP_CMD,
|
|
26
|
+
LISTEVENT_RESP_CMD,
|
|
25
27
|
decode_event_record,
|
|
28
|
+
decode_hour_map,
|
|
26
29
|
decode_list_event_response,
|
|
27
30
|
)
|
|
28
31
|
|
|
@@ -138,6 +141,41 @@ def test_the_page_fields_do_not_shadow_tuple_methods():
|
|
|
138
141
|
assert page.record_count == EVENT_RECORD_LEN
|
|
139
142
|
|
|
140
143
|
|
|
144
|
+
def test_a_map_asked_for_as_records_is_refused_not_invented():
|
|
145
|
+
# The real 168-byte map is 14 x 12 bytes, so the record reading "works"
|
|
146
|
+
# and produces fourteen recordings that do not exist, dated year 0. A
|
|
147
|
+
# caller that knows which reply it holds must not be able to get them.
|
|
148
|
+
assert decode_list_event_response(
|
|
149
|
+
REAL_HASLISTEVENT, command=HASLISTEVENT_RESP_CMD) is None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_a_map_decodes_to_one_byte_per_hour():
|
|
153
|
+
hours = decode_hour_map(REAL_HASLISTEVENT)
|
|
154
|
+
assert hours is not None
|
|
155
|
+
assert len(hours) == 168, "seven days, one byte per hour"
|
|
156
|
+
assert not any(hours), "this capture is an empty card"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_a_map_with_footage_reports_which_hours():
|
|
160
|
+
payload = bytearray(REAL_HASLISTEVENT)
|
|
161
|
+
payload[12 + 3] = 1
|
|
162
|
+
payload[12 + 100] = 2
|
|
163
|
+
hours = decode_hour_map(bytes(payload))
|
|
164
|
+
assert [i for i, h in enumerate(hours) if h] == [3, 100]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_a_record_page_is_not_a_map():
|
|
168
|
+
# Same guard the other way round: the map decoder must not hand back the
|
|
169
|
+
# record bytes as if they were hours.
|
|
170
|
+
assert decode_hour_map(
|
|
171
|
+
_page([_record()]), command=LISTEVENT_RESP_CMD) is None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_a_payload_too_short_to_be_a_map_decodes_to_nothing():
|
|
175
|
+
assert decode_hour_map(b"\x00" * 8) is None
|
|
176
|
+
assert decode_hour_map(None) is None
|
|
177
|
+
|
|
178
|
+
|
|
141
179
|
if __name__ == "__main__":
|
|
142
180
|
import traceback
|
|
143
181
|
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
{python_aidot_cameras-1.0.0b4 → python_aidot_cameras-1.0.0b5}/tests/test_sd_event_probe_payloads.py
RENAMED
|
@@ -21,14 +21,22 @@ import time
|
|
|
21
21
|
sys.path.insert(0, os.path.join(
|
|
22
22
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"))
|
|
23
23
|
|
|
24
|
+
from aidot_cameras.camera.sd_events import (
|
|
25
|
+
HASLISTEVENT_REQ_CMD,
|
|
26
|
+
HASLISTEVENT_RESP_CMD,
|
|
27
|
+
LISTEVENT_REQ_CMD,
|
|
28
|
+
LISTEVENT_RESP_CMD,
|
|
29
|
+
SD_EVENT_ANY,
|
|
30
|
+
SD_EVENT_APP,
|
|
31
|
+
haslistevent_payload,
|
|
32
|
+
listevent_payload,
|
|
33
|
+
stimeday,
|
|
34
|
+
)
|
|
24
35
|
from sd_event_probe import (
|
|
25
36
|
HASLISTEVENT_REQ,
|
|
26
37
|
HASLISTEVENT_RESP,
|
|
27
38
|
LISTEVENT_REQ,
|
|
28
39
|
LISTEVENT_RESP,
|
|
29
|
-
haslistevent_payload,
|
|
30
|
-
listevent_payload,
|
|
31
|
-
stimeday,
|
|
32
40
|
)
|
|
33
41
|
|
|
34
42
|
# 2026-08-11 12:34:56 UTC, a Tuesday.
|
|
@@ -65,11 +73,24 @@ def test_the_listevent_request_is_twenty_four_bytes():
|
|
|
65
73
|
assert len(listevent_payload(_WHEN - 3600, _WHEN)) == 24
|
|
66
74
|
|
|
67
75
|
|
|
68
|
-
def
|
|
69
|
-
#
|
|
70
|
-
#
|
|
76
|
+
def test_the_default_event_selector_is_the_one_that_answers():
|
|
77
|
+
# 0, not the app's 0x12. Measured 2026-08-11 on an A000088 with a card:
|
|
78
|
+
# the same session and window answered an EMPTY page for 0x12 and four
|
|
79
|
+
# real records for 0. The default carries the measurement so a caller
|
|
80
|
+
# that says nothing gets the selector that works.
|
|
71
81
|
p = listevent_payload(_WHEN - 3600, _WHEN)
|
|
72
|
-
assert p[20] ==
|
|
82
|
+
assert p[20] == 0 == SD_EVENT_ANY
|
|
83
|
+
assert SD_EVENT_APP == 0x12
|
|
84
|
+
assert listevent_payload(_WHEN - 3600, _WHEN, event=SD_EVENT_APP)[20] == 0x12
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_the_probe_and_the_package_agree_on_the_command_ids():
|
|
88
|
+
# One definition, two names: the probe re-exports what the package ships,
|
|
89
|
+
# so a divergence here means the probe stopped testing the shipped code.
|
|
90
|
+
assert (HASLISTEVENT_REQ, LISTEVENT_REQ) == (
|
|
91
|
+
HASLISTEVENT_REQ_CMD, LISTEVENT_REQ_CMD)
|
|
92
|
+
assert (HASLISTEVENT_RESP, LISTEVENT_RESP) == (
|
|
93
|
+
HASLISTEVENT_RESP_CMD, LISTEVENT_RESP_CMD)
|
|
73
94
|
|
|
74
95
|
|
|
75
96
|
def test_the_channel_leads_and_the_first_time_starts_at_offset_four():
|