python-aidot-cameras 0.13.2__tar.gz → 0.15.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_aidot_cameras-0.13.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.15.0}/PKG-INFO +1 -1
- python_aidot_cameras-0.15.0/aidot_cameras/ble_gateway.py +564 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/client.py +32 -5
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/constants.py +52 -0
- python_aidot_cameras-0.15.0/aidot_cameras/camera/hwaccel.py +447 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/protocol.py +48 -1
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/sdes.py +31 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/sdes_open.py +254 -16
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/webrtc_open.py +35 -9
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/client.py +6 -2
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/pyproject.toml +16 -1
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/SOURCES.txt +13 -0
- python_aidot_cameras-0.15.0/tests/test_answer_sdp_video_pt.py +181 -0
- python_aidot_cameras-0.15.0/tests/test_audio_mux_offline.py +147 -0
- python_aidot_cameras-0.15.0/tests/test_ble_gateway.py +393 -0
- python_aidot_cameras-0.15.0/tests/test_env_seams.py +156 -0
- python_aidot_cameras-0.15.0/tests/test_hwaccel_probe.py +303 -0
- python_aidot_cameras-0.15.0/tests/test_media_wait_floor.py +38 -0
- python_aidot_cameras-0.15.0/tests/test_restart_cycle.py +103 -0
- python_aidot_cameras-0.15.0/tests/test_scripts_importable.py +50 -0
- python_aidot_cameras-0.15.0/tests/test_sdes_terminal_ack_latency.py +138 -0
- python_aidot_cameras-0.15.0/tests/test_sdp_narrowing_properties.py +133 -0
- python_aidot_cameras-0.15.0/tests/test_sprop_staleness.py +83 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/LICENSE +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/README.md +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_upstream.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/device_client.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/setup.cfg +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_battery_detection.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_battery_evidence_corroboration.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_battery_skip_turn.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_constants_imports_resolve.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dormant_camera_cleanup.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_mux_terminal_write.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_ice_peer_reflexive.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_live_validate_policy.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_peerid_reuse.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_terminal_ack.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_sdp_payload_type_narrowing.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_shared_house_seam.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_turn_create_permission.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_viewer_present_push_mode.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_wait_or_event.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.15.0}/tests/test_wake_readiness_retry.py +0 -0
{python_aidot_cameras-0.13.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.15.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-aidot-cameras
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.0
|
|
4
4
|
Summary: Control AiDot/Leedarson WiFi lights and cameras (WebRTC streaming, two-way audio, PTZ, controls)
|
|
5
5
|
Author-email: cbrightly <chris.brightly@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""Control for BLE-mesh devices that sit behind an AiDot mesh hub.
|
|
2
|
+
|
|
3
|
+
A BLE-mesh bulb has no IP of its own: it is reached by talking to its **hub**
|
|
4
|
+
(``type == "BleMesh_Hub"``) over the hub's local TCP:10000 control channel and
|
|
5
|
+
naming the child in the payload. The wire format is the one
|
|
6
|
+
:mod:`aidot_cameras.camera.lan_control` already speaks - 8-byte header
|
|
7
|
+
(``magic 0x1EED`` + ``msgtype:int16`` + ``bodysize:int32``) over an AES-ECB JSON
|
|
8
|
+
body - with three differences:
|
|
9
|
+
|
|
10
|
+
* the socket, ``aesKey`` and ``password`` are the **hub's**, not the child's;
|
|
11
|
+
* ``payload.parentId`` is the hub id while ``payload.devId`` / ``deviceId`` name
|
|
12
|
+
the child;
|
|
13
|
+
* ``payload.channel`` is ``"ble"`` rather than ``"tcp"``, which is what tells the
|
|
14
|
+
hub to relay the command onto the mesh.
|
|
15
|
+
|
|
16
|
+
**Session policy.** The camera client opens a short-lived session per command
|
|
17
|
+
because that channel evicts an existing login on the next ``loginReq``. A hub
|
|
18
|
+
multiplexes every bulb behind it, so paying connect+login per command shows up
|
|
19
|
+
directly as slider lag. This module keeps one lock-serialised connection per hub
|
|
20
|
+
and closes it after :data:`_IDLE_CLOSE_S` of silence - bursts (a brightness drag)
|
|
21
|
+
reuse one login, and an idle hub is left free for the app rather than being held
|
|
22
|
+
indefinitely.
|
|
23
|
+
|
|
24
|
+
Nothing here is Home Assistant specific: it is the hub-relay transport plus the
|
|
25
|
+
cloud-record predicates needed to recognise which devices use it.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import ctypes
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from typing import Any, Optional
|
|
38
|
+
|
|
39
|
+
# Single source of truth for the local control wire format - the hub relay is the
|
|
40
|
+
# same framing as the direct-to-device channel, so it reuses those helpers rather
|
|
41
|
+
# than restating the header layout (and its frame-size cap) a second time.
|
|
42
|
+
from .camera.lan_control import _pack, _read_frame
|
|
43
|
+
from .crypto import aes_decrypt, aes_encrypt
|
|
44
|
+
|
|
45
|
+
_LOGGER = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
_CONTROL_PORT = 10000
|
|
48
|
+
_CONNECT_TIMEOUT = 8.0
|
|
49
|
+
_READ_TIMEOUT = 8.0
|
|
50
|
+
_MAX_RETRIES = 3
|
|
51
|
+
_BACKOFF_S = 0.5
|
|
52
|
+
# How long a hub connection is kept warm after the last command. Long enough to
|
|
53
|
+
# coalesce a slider drag, short enough that an idle hub is not held against the
|
|
54
|
+
# phone app.
|
|
55
|
+
_IDLE_CLOSE_S = 5.0
|
|
56
|
+
|
|
57
|
+
BLE_MESH_HUB_TYPE = "BleMesh_Hub"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BleMeshError(Exception):
|
|
61
|
+
"""A hub-relayed control operation failed (connect, login, or protocol)."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------- #
|
|
65
|
+
# Cloud-record predicates
|
|
66
|
+
# --------------------------------------------------------------------------- #
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def find_ble_mesh_hubs(devices: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
70
|
+
"""Return ``{hub_id: device}`` for every BLE-mesh hub in a cloud device list."""
|
|
71
|
+
return {
|
|
72
|
+
d["id"]: d
|
|
73
|
+
for d in devices
|
|
74
|
+
if d.get("type") == BLE_MESH_HUB_TYPE and d.get("id")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def hub_id_of(device: dict[str, Any]) -> str:
|
|
79
|
+
"""Return the id of the hub a device sits behind, or ``""`` if it is direct.
|
|
80
|
+
|
|
81
|
+
The cloud record is not consistent about which field carries this:
|
|
82
|
+
``directGateway`` is the documented one, but ``parentId`` is populated on some
|
|
83
|
+
firmware. A device that is not proxied reports both as ``""`` and sets
|
|
84
|
+
``directId`` to its own id, so an empty result here means "talks for itself".
|
|
85
|
+
"""
|
|
86
|
+
for key in ("directGateway", "parentId"):
|
|
87
|
+
value = device.get(key)
|
|
88
|
+
if value and value != device.get("id"):
|
|
89
|
+
return str(value)
|
|
90
|
+
return ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_ble_mesh_child(
|
|
94
|
+
device: dict[str, Any], hubs: dict[str, dict[str, Any]]
|
|
95
|
+
) -> bool:
|
|
96
|
+
"""True when ``device`` must be driven through one of ``hubs``.
|
|
97
|
+
|
|
98
|
+
Requires both that the record points at a known hub **and** that it carries
|
|
99
|
+
mesh addressing (``bleMeshDeviceKey`` / ``bleMeshAddr``). The corroboration
|
|
100
|
+
matters: a hub id alone also appears on Zigbee children and on records that
|
|
101
|
+
merely share a house, and building a mesh client for one of those produces a
|
|
102
|
+
device that accepts commands and silently controls nothing.
|
|
103
|
+
"""
|
|
104
|
+
if hub_id_of(device) not in hubs:
|
|
105
|
+
return False
|
|
106
|
+
return bool(device.get("bleMeshDeviceKey")) or bool(device.get("bleMeshAddr"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
# Device state
|
|
111
|
+
# --------------------------------------------------------------------------- #
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class BleMeshDeviceInfo:
|
|
116
|
+
"""Static description of a mesh device, derived from its cloud record."""
|
|
117
|
+
|
|
118
|
+
dev_id: str
|
|
119
|
+
hub_id: str
|
|
120
|
+
model_id: str
|
|
121
|
+
mac: str
|
|
122
|
+
name: str
|
|
123
|
+
hw_version: Optional[str] = None
|
|
124
|
+
enable_rgbw: bool = False
|
|
125
|
+
enable_cct: bool = False
|
|
126
|
+
enable_dimming: bool = False
|
|
127
|
+
cct_min: int = 2700
|
|
128
|
+
cct_max: int = 6500
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class BleMeshDeviceStatus:
|
|
133
|
+
"""Last-known state of a mesh device.
|
|
134
|
+
|
|
135
|
+
Mesh children do not push status - the hub acks a command and says nothing
|
|
136
|
+
afterwards - so these values are seeded from the cloud record and then
|
|
137
|
+
advanced optimistically by each accepted command.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
online: bool = True
|
|
141
|
+
on: bool = False
|
|
142
|
+
dimming: int = 255
|
|
143
|
+
cct: int = 2700
|
|
144
|
+
rgbw: tuple[int, int, int, int] = field(default_factory=lambda: (255, 255, 255, 0))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _unpack_rgbw(raw: Any) -> tuple[int, int, int, int]:
|
|
148
|
+
"""Split a packed RGBW int into components.
|
|
149
|
+
|
|
150
|
+
The cloud sends this as a *signed* 32-bit value, so any colour with the red
|
|
151
|
+
channel above 0x7F arrives negative; masking through ``c_uint32`` first is
|
|
152
|
+
what keeps those from shifting into nonsense.
|
|
153
|
+
"""
|
|
154
|
+
packed = ctypes.c_uint32(int(raw or 0)).value
|
|
155
|
+
return (
|
|
156
|
+
(packed >> 24) & 0xFF,
|
|
157
|
+
(packed >> 16) & 0xFF,
|
|
158
|
+
(packed >> 8) & 0xFF,
|
|
159
|
+
packed & 0xFF,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _pack_rgbw(rgbw: tuple[int, int, int, int]) -> int:
|
|
164
|
+
"""Pack RGBW components into the signed 32-bit int the wire format expects."""
|
|
165
|
+
packed = (
|
|
166
|
+
((rgbw[0] & 0xFF) << 24)
|
|
167
|
+
| ((rgbw[1] & 0xFF) << 16)
|
|
168
|
+
| ((rgbw[2] & 0xFF) << 8)
|
|
169
|
+
| (rgbw[3] & 0xFF)
|
|
170
|
+
)
|
|
171
|
+
return ctypes.c_int32(packed).value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _service_modules(device: dict[str, Any]) -> list[dict[str, Any]]:
|
|
175
|
+
return (device.get("product") or {}).get("serviceModules") or []
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _cct_range(device: dict[str, Any]) -> tuple[int, int]:
|
|
179
|
+
"""Read the device's CCT limits, falling back to the common 2700-6500 K."""
|
|
180
|
+
cct_min, cct_max = 2700, 6500
|
|
181
|
+
for module in _service_modules(device):
|
|
182
|
+
if (module.get("identity") or "").lower() != "control.light.cct":
|
|
183
|
+
continue
|
|
184
|
+
for prop in module.get("properties") or []:
|
|
185
|
+
if (prop.get("identity") or "").upper() != "CCT":
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
cct_min = int(prop.get("minValue", cct_min))
|
|
189
|
+
cct_max = int(prop.get("maxValue", cct_max))
|
|
190
|
+
except (TypeError, ValueError):
|
|
191
|
+
pass
|
|
192
|
+
return cct_min, cct_max
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --------------------------------------------------------------------------- #
|
|
196
|
+
# Hub session
|
|
197
|
+
# --------------------------------------------------------------------------- #
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class BleMeshHubSession:
|
|
201
|
+
"""One lock-serialised, idle-closed connection to a mesh hub.
|
|
202
|
+
|
|
203
|
+
Every child behind a hub shares this object: the hub resets the socket when a
|
|
204
|
+
second login lands, so commands must be serialised rather than merely
|
|
205
|
+
connection-pooled. A dropped connection is retried, which also covers the
|
|
206
|
+
case where the hub closed an idle socket before our own timer fired.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(
|
|
210
|
+
self,
|
|
211
|
+
hub_id: str,
|
|
212
|
+
hub_ip: str,
|
|
213
|
+
aes_key: bytes,
|
|
214
|
+
user_id: str,
|
|
215
|
+
hub_password: str,
|
|
216
|
+
idle_close_s: float = _IDLE_CLOSE_S,
|
|
217
|
+
) -> None:
|
|
218
|
+
self.hub_id = hub_id
|
|
219
|
+
self.hub_ip = hub_ip
|
|
220
|
+
self._key = aes_key
|
|
221
|
+
self._user_id = user_id
|
|
222
|
+
self._password = hub_password
|
|
223
|
+
self._idle_close_s = idle_close_s
|
|
224
|
+
self._lock = asyncio.Lock()
|
|
225
|
+
self._reader: Optional[asyncio.StreamReader] = None
|
|
226
|
+
self._writer: Optional[asyncio.StreamWriter] = None
|
|
227
|
+
self._asc = 1
|
|
228
|
+
self._idle_task: Optional[asyncio.Task] = None
|
|
229
|
+
|
|
230
|
+
async def async_send_attributes(self, dev_id: str, attr: dict[str, Any]) -> bool:
|
|
231
|
+
"""Relay one ``setDevAttrReq`` to ``dev_id``; True on a ``setDevAttrResp``."""
|
|
232
|
+
async with self._lock:
|
|
233
|
+
self._cancel_idle_timer()
|
|
234
|
+
try:
|
|
235
|
+
for attempt in range(_MAX_RETRIES):
|
|
236
|
+
try:
|
|
237
|
+
await self._ensure_connected()
|
|
238
|
+
return await self._set_attr(dev_id, attr)
|
|
239
|
+
except (
|
|
240
|
+
OSError,
|
|
241
|
+
TimeoutError,
|
|
242
|
+
asyncio.IncompleteReadError,
|
|
243
|
+
) as exc:
|
|
244
|
+
await self._close()
|
|
245
|
+
if attempt == _MAX_RETRIES - 1:
|
|
246
|
+
raise BleMeshError(
|
|
247
|
+
f"{self.hub_id}: relay to {dev_id} failed: {exc}"
|
|
248
|
+
) from exc
|
|
249
|
+
await asyncio.sleep(_BACKOFF_S * (attempt + 1))
|
|
250
|
+
raise BleMeshError(f"{self.hub_id}: relay to {dev_id} exhausted retries")
|
|
251
|
+
finally:
|
|
252
|
+
self._start_idle_timer()
|
|
253
|
+
|
|
254
|
+
# -- connection lifecycle ---------------------------------------------- #
|
|
255
|
+
|
|
256
|
+
async def _ensure_connected(self) -> None:
|
|
257
|
+
if self._writer is None or self._writer.is_closing():
|
|
258
|
+
await self._connect_and_login()
|
|
259
|
+
|
|
260
|
+
async def _connect_and_login(self) -> None:
|
|
261
|
+
self._reader, self._writer = await asyncio.wait_for(
|
|
262
|
+
asyncio.open_connection(self.hub_ip, _CONTROL_PORT),
|
|
263
|
+
timeout=_CONNECT_TIMEOUT,
|
|
264
|
+
)
|
|
265
|
+
msg = {
|
|
266
|
+
"service": "device",
|
|
267
|
+
"method": "loginReq",
|
|
268
|
+
"seq": str(int(time.time() * 1000))[-9:],
|
|
269
|
+
"srcAddr": self._user_id,
|
|
270
|
+
"deviceId": self.hub_id,
|
|
271
|
+
"payload": {
|
|
272
|
+
"userId": self._user_id,
|
|
273
|
+
"password": self._password,
|
|
274
|
+
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S.000"),
|
|
275
|
+
"ascNumber": 1,
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
self._writer.write(_pack(1, aes_encrypt(json.dumps(msg).encode(), self._key)))
|
|
279
|
+
await self._writer.drain()
|
|
280
|
+
try:
|
|
281
|
+
resp = json.loads(
|
|
282
|
+
aes_decrypt(await _read_frame(self._reader, _READ_TIMEOUT), self._key)
|
|
283
|
+
)
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
await self._close()
|
|
286
|
+
# A host that cannot answer with something our hub key decrypts is not
|
|
287
|
+
# the hub. Surfacing that distinctly matters because the hub's IP comes
|
|
288
|
+
# from an unauthenticated cloud "properties" field that can go stale
|
|
289
|
+
# onto whatever else now holds the address.
|
|
290
|
+
raise BleMeshError(
|
|
291
|
+
f"{self.hub_id}: login response undecryptable (wrong host?)"
|
|
292
|
+
) from exc
|
|
293
|
+
ack = (resp.get("ack") or {}).get("code")
|
|
294
|
+
if ack not in (None, 200):
|
|
295
|
+
await self._close()
|
|
296
|
+
raise BleMeshError(f"{self.hub_id}: login rejected ack={ack}")
|
|
297
|
+
self._asc = (resp.get("payload") or {}).get("ascNumber", 1) + 1
|
|
298
|
+
|
|
299
|
+
async def _set_attr(self, dev_id: str, attr: dict[str, Any]) -> bool:
|
|
300
|
+
assert self._writer is not None and self._reader is not None
|
|
301
|
+
msg = {
|
|
302
|
+
"method": "setDevAttrReq",
|
|
303
|
+
"service": "device",
|
|
304
|
+
"clientId": "ha-" + self._user_id,
|
|
305
|
+
"srcAddr": "0." + self._user_id,
|
|
306
|
+
"seq": "b" + str(int(time.time() * 1000))[-9:],
|
|
307
|
+
"payload": {
|
|
308
|
+
"devId": dev_id,
|
|
309
|
+
"parentId": self.hub_id,
|
|
310
|
+
"userId": self._user_id,
|
|
311
|
+
"password": self._password,
|
|
312
|
+
"attr": attr,
|
|
313
|
+
"channel": "ble",
|
|
314
|
+
"ascNumber": self._asc,
|
|
315
|
+
},
|
|
316
|
+
"tst": int(time.time() * 1000),
|
|
317
|
+
"deviceId": dev_id,
|
|
318
|
+
}
|
|
319
|
+
self._asc += 1
|
|
320
|
+
self._writer.write(_pack(1, aes_encrypt(json.dumps(msg).encode(), self._key)))
|
|
321
|
+
await self._writer.drain()
|
|
322
|
+
# The hub may emit an unrelated frame (a status echo for another child on
|
|
323
|
+
# the mesh) before our ack, so read until the response method arrives.
|
|
324
|
+
for _ in range(4):
|
|
325
|
+
frame = json.loads(
|
|
326
|
+
aes_decrypt(await _read_frame(self._reader, _READ_TIMEOUT), self._key)
|
|
327
|
+
)
|
|
328
|
+
method = str(frame.get("method") or "")
|
|
329
|
+
if method == "setDevAttrResp":
|
|
330
|
+
return True
|
|
331
|
+
if method.endswith("Resp"):
|
|
332
|
+
return False
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
def _start_idle_timer(self) -> None:
|
|
336
|
+
if self._writer is None or self._idle_close_s <= 0:
|
|
337
|
+
return
|
|
338
|
+
loop = asyncio.get_running_loop()
|
|
339
|
+
self._idle_task = loop.create_task(self._idle_close())
|
|
340
|
+
|
|
341
|
+
def _cancel_idle_timer(self) -> None:
|
|
342
|
+
task = self._idle_task
|
|
343
|
+
self._idle_task = None
|
|
344
|
+
if task is not None and not task.done():
|
|
345
|
+
task.cancel()
|
|
346
|
+
|
|
347
|
+
async def _idle_close(self) -> None:
|
|
348
|
+
try:
|
|
349
|
+
await asyncio.sleep(self._idle_close_s)
|
|
350
|
+
except asyncio.CancelledError:
|
|
351
|
+
return
|
|
352
|
+
# Take the lock so this can never close a socket mid-command.
|
|
353
|
+
async with self._lock:
|
|
354
|
+
if self._idle_task is not None:
|
|
355
|
+
await self._close()
|
|
356
|
+
|
|
357
|
+
async def async_close(self) -> None:
|
|
358
|
+
"""Close the hub connection and stop the idle timer."""
|
|
359
|
+
self._cancel_idle_timer()
|
|
360
|
+
async with self._lock:
|
|
361
|
+
await self._close()
|
|
362
|
+
|
|
363
|
+
async def _close(self) -> None:
|
|
364
|
+
writer, self._writer, self._reader = self._writer, None, None
|
|
365
|
+
if writer is None:
|
|
366
|
+
return
|
|
367
|
+
try:
|
|
368
|
+
writer.close()
|
|
369
|
+
await writer.wait_closed()
|
|
370
|
+
except Exception: # teardown is best-effort
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
_HUB_SESSIONS: dict[str, BleMeshHubSession] = {}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def get_hub_session(
|
|
378
|
+
hub_id: str, hub_ip: str, aes_key: bytes, user_id: str, hub_password: str
|
|
379
|
+
) -> BleMeshHubSession:
|
|
380
|
+
"""Return the process-wide session for a hub, creating it on first use.
|
|
381
|
+
|
|
382
|
+
Keyed by hub id; a changed IP (DHCP move) replaces the session rather than
|
|
383
|
+
reusing one pointed at the old address.
|
|
384
|
+
"""
|
|
385
|
+
session = _HUB_SESSIONS.get(hub_id)
|
|
386
|
+
if session is None or session.hub_ip != hub_ip:
|
|
387
|
+
session = BleMeshHubSession(hub_id, hub_ip, aes_key, user_id, hub_password)
|
|
388
|
+
_HUB_SESSIONS[hub_id] = session
|
|
389
|
+
return session
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def close_all_hub_sessions() -> None:
|
|
393
|
+
"""Close every open hub connection and clear the registry (call on unload)."""
|
|
394
|
+
sessions = list(_HUB_SESSIONS.values())
|
|
395
|
+
_HUB_SESSIONS.clear()
|
|
396
|
+
for session in sessions:
|
|
397
|
+
await session.async_close()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# --------------------------------------------------------------------------- #
|
|
401
|
+
# Per-device client
|
|
402
|
+
# --------------------------------------------------------------------------- #
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class BleMeshGatewayClient:
|
|
406
|
+
"""Drives one BLE-mesh light by relaying commands through its hub.
|
|
407
|
+
|
|
408
|
+
Shaped to match the attribute surface the device clients expose (``info``,
|
|
409
|
+
``status``, ``on_status_update``) so a consumer can treat it as one more
|
|
410
|
+
client flavour rather than a special case.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
def __init__(
|
|
414
|
+
self,
|
|
415
|
+
device: dict[str, Any],
|
|
416
|
+
hub_device: dict[str, Any],
|
|
417
|
+
user_id: str,
|
|
418
|
+
) -> None:
|
|
419
|
+
self.device_id: str = device.get("id") or ""
|
|
420
|
+
if not self.device_id:
|
|
421
|
+
raise BleMeshError("mesh device record has no id")
|
|
422
|
+
|
|
423
|
+
self._hub_id: str = hub_device.get("id") or ""
|
|
424
|
+
self._hub_ip: str = (hub_device.get("properties") or {}).get("ipAddress") or ""
|
|
425
|
+
if not self._hub_id:
|
|
426
|
+
raise BleMeshError(f"{self.device_id}: hub record has no id")
|
|
427
|
+
if not self._hub_ip:
|
|
428
|
+
raise BleMeshError(f"{self.device_id}: hub {self._hub_id} has no ipAddress")
|
|
429
|
+
|
|
430
|
+
aes = hub_device.get("aesKey") or []
|
|
431
|
+
key_str = aes[0] if isinstance(aes, list) and aes else (aes or "")
|
|
432
|
+
if not key_str:
|
|
433
|
+
raise BleMeshError(f"{self.device_id}: hub {self._hub_id} has no aesKey")
|
|
434
|
+
key = bytearray(16)
|
|
435
|
+
raw = str(key_str).encode()
|
|
436
|
+
key[: len(raw)] = raw[:16]
|
|
437
|
+
self._key = bytes(key)
|
|
438
|
+
self._hub_password: str = hub_device.get("password") or ""
|
|
439
|
+
self._user_id = user_id
|
|
440
|
+
|
|
441
|
+
modules = {
|
|
442
|
+
(m.get("identity") or "").lower() for m in _service_modules(device)
|
|
443
|
+
}
|
|
444
|
+
cct_min, cct_max = _cct_range(device)
|
|
445
|
+
self.info = BleMeshDeviceInfo(
|
|
446
|
+
dev_id=self.device_id,
|
|
447
|
+
hub_id=self._hub_id,
|
|
448
|
+
model_id=device.get("modelId") or "unknown",
|
|
449
|
+
mac=device.get("mac") or "",
|
|
450
|
+
name=device.get("name") or "",
|
|
451
|
+
hw_version=device.get("hardwareVersion"),
|
|
452
|
+
enable_rgbw="control.light.rgbw" in modules,
|
|
453
|
+
enable_cct="control.light.cct" in modules,
|
|
454
|
+
enable_dimming="control.light.dimming" in modules,
|
|
455
|
+
cct_min=cct_min,
|
|
456
|
+
cct_max=cct_max,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
props = device.get("properties") or {}
|
|
460
|
+
self.status = BleMeshDeviceStatus(
|
|
461
|
+
online=bool(device.get("online", True)),
|
|
462
|
+
on=str(props.get("OnOff", "0")) == "1",
|
|
463
|
+
# The cloud reports Dimming as 0-100; the client surface is 0-255.
|
|
464
|
+
dimming=_pct_to_255(props.get("Dimming", 100)),
|
|
465
|
+
cct=_as_int(props.get("CCT"), cct_min),
|
|
466
|
+
rgbw=_unpack_rgbw(props.get("RGBW", 0)),
|
|
467
|
+
)
|
|
468
|
+
self.on_status_update: Any = None
|
|
469
|
+
|
|
470
|
+
# -- commands ----------------------------------------------------------- #
|
|
471
|
+
|
|
472
|
+
async def async_set_attributes(self, attr: dict[str, Any]) -> bool:
|
|
473
|
+
"""Relay ``attr`` (on-the-wire keys) and advance the optimistic status."""
|
|
474
|
+
session = get_hub_session(
|
|
475
|
+
self._hub_id, self._hub_ip, self._key, self._user_id, self._hub_password
|
|
476
|
+
)
|
|
477
|
+
acked = await session.async_send_attributes(self.device_id, attr)
|
|
478
|
+
if not acked:
|
|
479
|
+
_LOGGER.debug(
|
|
480
|
+
"mesh device %s: hub %s did not ack %s",
|
|
481
|
+
self.device_id,
|
|
482
|
+
self._hub_id,
|
|
483
|
+
sorted(attr),
|
|
484
|
+
)
|
|
485
|
+
return False
|
|
486
|
+
self._apply_optimistic(attr)
|
|
487
|
+
return True
|
|
488
|
+
|
|
489
|
+
def _apply_optimistic(self, attr: dict[str, Any]) -> None:
|
|
490
|
+
"""Fold an acked command into ``status``.
|
|
491
|
+
|
|
492
|
+
Mesh children never report back, so this is the only thing that moves
|
|
493
|
+
state between cloud refreshes. It runs *only* after an ack, so a command
|
|
494
|
+
the hub dropped does not leave a state the device never reached.
|
|
495
|
+
"""
|
|
496
|
+
if "OnOff" in attr:
|
|
497
|
+
self.status.on = bool(attr["OnOff"])
|
|
498
|
+
if "Dimming" in attr:
|
|
499
|
+
self.status.dimming = _pct_to_255(attr["Dimming"])
|
|
500
|
+
if "CCT" in attr:
|
|
501
|
+
self.status.cct = _as_int(attr["CCT"], self.status.cct)
|
|
502
|
+
if "RGBW" in attr:
|
|
503
|
+
self.status.rgbw = _unpack_rgbw(attr["RGBW"])
|
|
504
|
+
if self.on_status_update:
|
|
505
|
+
self.on_status_update(self.status)
|
|
506
|
+
|
|
507
|
+
async def async_turn_on(self) -> bool:
|
|
508
|
+
return await self.async_set_attributes({"OnOff": 1})
|
|
509
|
+
|
|
510
|
+
async def async_turn_off(self) -> bool:
|
|
511
|
+
return await self.async_set_attributes({"OnOff": 0})
|
|
512
|
+
|
|
513
|
+
async def async_set_brightness(self, brightness: int) -> bool:
|
|
514
|
+
"""Set brightness from a 0-255 value (the wire field is 0-100)."""
|
|
515
|
+
return await self.async_set_attributes(
|
|
516
|
+
{"OnOff": 1, "Dimming": _255_to_pct(brightness)}
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
async def async_set_cct(self, cct: int) -> bool:
|
|
520
|
+
clamped = max(self.info.cct_min, min(self.info.cct_max, int(cct)))
|
|
521
|
+
return await self.async_set_attributes({"OnOff": 1, "CCT": clamped})
|
|
522
|
+
|
|
523
|
+
async def async_set_rgbw(self, rgbw: tuple[int, int, int, int]) -> bool:
|
|
524
|
+
return await self.async_set_attributes(
|
|
525
|
+
{"OnOff": 1, "RGBW": _pack_rgbw(rgbw)}
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
def update_status_from_device(self, device: dict[str, Any]) -> None:
|
|
529
|
+
"""Re-seed status from a fresh cloud record (the only inbound signal)."""
|
|
530
|
+
props = device.get("properties") or {}
|
|
531
|
+
self.status.online = bool(device.get("online", self.status.online))
|
|
532
|
+
if "OnOff" in props:
|
|
533
|
+
self.status.on = str(props["OnOff"]) == "1"
|
|
534
|
+
if "Dimming" in props:
|
|
535
|
+
self.status.dimming = _pct_to_255(props["Dimming"])
|
|
536
|
+
if "CCT" in props:
|
|
537
|
+
self.status.cct = _as_int(props["CCT"], self.status.cct)
|
|
538
|
+
if "RGBW" in props:
|
|
539
|
+
self.status.rgbw = _unpack_rgbw(props["RGBW"])
|
|
540
|
+
if self.on_status_update:
|
|
541
|
+
self.on_status_update(self.status)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _as_int(value: Any, default: int) -> int:
|
|
545
|
+
try:
|
|
546
|
+
return int(value)
|
|
547
|
+
except (TypeError, ValueError):
|
|
548
|
+
return default
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _pct_to_255(value: Any) -> int:
|
|
552
|
+
"""0-100 wire percentage -> 0-255, clamped."""
|
|
553
|
+
pct = max(0, min(100, _as_int(value, 100)))
|
|
554
|
+
return round(pct * 255 / 100)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _255_to_pct(value: Any) -> int:
|
|
558
|
+
"""0-255 -> 0-100 wire percentage, clamped to a minimum of 1.
|
|
559
|
+
|
|
560
|
+
Rounding 1-2/255 down to 0% would send "off" on a brightness command, which
|
|
561
|
+
reads as the light ignoring the slider.
|
|
562
|
+
"""
|
|
563
|
+
raw = max(0, min(255, _as_int(value, 255)))
|
|
564
|
+
return max(1, round(raw * 100 / 255))
|
|
@@ -22,9 +22,11 @@ from ..const import (
|
|
|
22
22
|
from .constants import (
|
|
23
23
|
_LEEDARSON_APP_ID,
|
|
24
24
|
_LIVE_PLAY_NOT_READY,
|
|
25
|
-
_SMARTHOME_URL_TEMPLATE,
|
|
25
|
+
_SMARTHOME_URL_TEMPLATE, # noqa: F401 - re-exported for back-compat; reads go through smarthome_base()
|
|
26
26
|
GETSTREAMCTRL_CMD, # noqa: F401 - re-exported for back-compat (public pair; unused in-module)
|
|
27
27
|
SDES_SPEAKERSTART_DELAY, # noqa: F401 - used by the sdes_open mixin; kept re-exported
|
|
28
|
+
aidot_api_base,
|
|
29
|
+
smarthome_base,
|
|
28
30
|
)
|
|
29
31
|
from .constants import ( # noqa: F401 - re-exported for device_client / back-compat
|
|
30
32
|
TALK_PCM_FRAME_BYTES,
|
|
@@ -372,6 +374,7 @@ def _build_sdes_serve_cmd(
|
|
|
372
374
|
sdes_audio: bool = False,
|
|
373
375
|
audio_gain_db: float = -8.0,
|
|
374
376
|
push_video_only: bool = False,
|
|
377
|
+
video_decoder: Optional[list] = None,
|
|
375
378
|
) -> list:
|
|
376
379
|
"""Build the ffmpeg argv for the SDES bridge serve.
|
|
377
380
|
|
|
@@ -445,10 +448,25 @@ def _build_sdes_serve_cmd(
|
|
|
445
448
|
dest_args = ["-c", "copy", *time_args, output_path]
|
|
446
449
|
else:
|
|
447
450
|
dest_args = [*time_args, "-f", "null", "/dev/null"]
|
|
451
|
+
|
|
452
|
+
# Decoder selection applies to the ``-f null`` drain alone - it is the only
|
|
453
|
+
# destination that decodes; every other one is ``-c copy``. The caller
|
|
454
|
+
# passes ready-made input arguments, either ``["-hwaccel", m]`` or
|
|
455
|
+
# ``["-c:v", name]``, and they must sit BEFORE ``-i``: after ``-i`` ffmpeg
|
|
456
|
+
# reads ``-c:v`` as an ENCODER, and ``-hwaccel`` is an input option that has
|
|
457
|
+
# no meaning there at all. Empty or None means ffmpeg chooses, as always. Keep the decode: it is what proves frames are not merely
|
|
458
|
+
# arriving but decodable, which is what catches a parameter-set mismatch
|
|
459
|
+
# that otherwise presents as a permanently black picture.
|
|
460
|
+
decode_args = (
|
|
461
|
+
list(video_decoder)
|
|
462
|
+
if video_decoder and not rtsp_push_url and not output_path
|
|
463
|
+
else []
|
|
464
|
+
)
|
|
448
465
|
return [
|
|
449
466
|
"ffmpeg", "-y",
|
|
450
467
|
"-loglevel", "warning",
|
|
451
468
|
"-protocol_whitelist", "file,rtp,udp,srtp",
|
|
469
|
+
*decode_args,
|
|
452
470
|
# 2 s analyzeduration: the camera sends SPS+PPS+IDR in the first burst;
|
|
453
471
|
# 15 s consumed nearly all packets during analysis. PLI re-arms an IDR
|
|
454
472
|
# every 5 s so parameters always re-arrive.
|
|
@@ -1156,7 +1174,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1156
1174
|
|
|
1157
1175
|
@property
|
|
1158
1176
|
def _smarthome_base(self) -> str:
|
|
1159
|
-
return
|
|
1177
|
+
return smarthome_base(self._region)
|
|
1160
1178
|
|
|
1161
1179
|
def set_token_refresh_cb(self, cb: "Callable") -> None:
|
|
1162
1180
|
"""Register the AidotClient coroutine that refreshes the access token."""
|
|
@@ -1371,6 +1389,15 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1371
1389
|
if self._mqtt_url:
|
|
1372
1390
|
return self._mqtt_url
|
|
1373
1391
|
|
|
1392
|
+
# Test seam: point the whole client at a local broker without any cloud
|
|
1393
|
+
# call (paho derives ws-vs-wss and the port from the scheme, so a plain
|
|
1394
|
+
# "ws://127.0.0.1:PORT/mqtt" needs nothing else). Unset in production.
|
|
1395
|
+
_env_mqtt = os.environ.get("AIDOT_MQTT_URL")
|
|
1396
|
+
if _env_mqtt:
|
|
1397
|
+
_LOGGER.debug("AIDOT_MQTT_URL override in effect: %s", _env_mqtt)
|
|
1398
|
+
self._mqtt_url = _env_mqtt
|
|
1399
|
+
return self._mqtt_url
|
|
1400
|
+
|
|
1374
1401
|
import aiohttp
|
|
1375
1402
|
|
|
1376
1403
|
async def _fetch():
|
|
@@ -1603,11 +1630,11 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
1603
1630
|
|
|
1604
1631
|
@property
|
|
1605
1632
|
def _aidot_v21_base(self) -> str:
|
|
1606
|
-
return f"
|
|
1633
|
+
return f"{aidot_api_base(self._region)}/v21"
|
|
1607
1634
|
|
|
1608
1635
|
@property
|
|
1609
1636
|
def _aidot_v32_base(self) -> str:
|
|
1610
|
-
return f"
|
|
1637
|
+
return f"{aidot_api_base(self._region)}/v32/api/ipc"
|
|
1611
1638
|
|
|
1612
1639
|
def _aidot_headers(self) -> dict:
|
|
1613
1640
|
# Auth headers for the AiDot platform API (prod-{region}-api.arnoo.com).
|
|
@@ -4847,7 +4874,7 @@ class CameraMixin(_CameraControlsMixin, _WebRTCOpenMixin, _SdesOpenMixin):
|
|
|
4847
4874
|
return None
|
|
4848
4875
|
|
|
4849
4876
|
url = (
|
|
4850
|
-
f"
|
|
4877
|
+
f"{aidot_api_base(self._region)}"
|
|
4851
4878
|
f"/v29/api/webrtc/iceConfig?forceRefresh=0"
|
|
4852
4879
|
)
|
|
4853
4880
|
|