python-aidot-cameras 0.13.2__tar.gz → 0.14.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.14.0}/PKG-INFO +1 -1
- python_aidot_cameras-0.14.0/aidot_cameras/ble_gateway.py +564 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/sdes_open.py +84 -2
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/pyproject.toml +1 -1
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0/python_aidot_cameras.egg-info}/PKG-INFO +1 -1
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/python_aidot_cameras.egg-info/SOURCES.txt +3 -0
- python_aidot_cameras-0.14.0/tests/test_answer_sdp_video_pt.py +181 -0
- python_aidot_cameras-0.14.0/tests/test_ble_gateway.py +393 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/LICENSE +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/MANIFEST.in +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/README.md +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/__main__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_upstream.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/clock.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/base.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/g711.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/g722.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/h264.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/opus.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/codecs/vpx.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/contrib/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/contrib/media.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/contrib/signaling.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/events.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/exceptions.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/jitterbuffer.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/mediastreams.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rate.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcconfiguration.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcdatachannel.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcdtlstransport.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcicetransport.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcpeerconnection.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcrtpparameters.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcrtpreceiver.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcrtpsender.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcrtptransceiver.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcsctptransport.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtcsessiondescription.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/rtp.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/sdp.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/stats.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/_vendor/aiortc/utils.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/__init__.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/client.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/constants.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/controls.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/go2rtc.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/lan_control.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/models.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/playback.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/protocol.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/sdes.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/tutk.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/webrtc.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/webrtc_open.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/client.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/const.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/credentials.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/crypto.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/device_client.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/discover.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/exceptions.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/g711.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/py.typed +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/python_aidot_cameras.egg-info/dependency_links.txt +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/python_aidot_cameras.egg-info/entry_points.txt +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/python_aidot_cameras.egg-info/requires.txt +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/python_aidot_cameras.egg-info/top_level.txt +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/setup.cfg +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_aioice_compat.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_alarm_event.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_backoff.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_battery_detection.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_battery_evidence_corroboration.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_battery_skip_turn.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_constants_imports_resolve.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_deferred_hardening.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_device_login_guard.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_device_user_info_cache.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dormant_camera_cleanup.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_mux_terminal_write.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_not_ready_burst.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_pinning.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_serve_decode_skip.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_serve_open_timeout.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_skip_signaling_wait.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_dtls_slow_probe.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_egress_guard.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_go2rtc.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_go2rtc_cli.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_h264_decode_rate_limit.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_highport_nomination.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_ice_config_cache.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_ice_peer_reflexive.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_idle_release_both_loops.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_keepalive_renew.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_key_restart_sdp.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_keyframe_prompter.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_lan_control.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_light_active_color_mode.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_live_stream_param.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_live_validate_policy.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_logging_caps.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_motion_poll.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_mqtt_credential_refetch.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_mqtt_credential_self_heal.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_mqtt_password_not_persisted.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_mqtt_session_reconnect.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_narrow_pc_ice.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_no_self_referential_go2rtc.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_no_undefined_names.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_offline_keepalive_pause.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_open_fail_logger.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_open_gate_delay.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_peerid_reuse.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_persistent_mqtt.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_persistent_mqtt_robustness.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_playback_tls.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_post_merge_hardening.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_retry_policy.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_adaptive.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_bridge_break_gate.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_echo_wait_timeout.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_fast_liveplay.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_ffmpeg_stderr_log.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_idle_release.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_open_cleanup.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_serve_audio.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_serve_cmd.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_sprop.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_talk.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_teardown_exit_log.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_terminal_ack.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdes_watchdog.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_sdp_payload_type_narrowing.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_security_hardening.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_serializable_login_info.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_serve_relay.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_session_stats.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_shared_house_seam.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_speak.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_stream_cap.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_stream_drain_ownership.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_stream_idle.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_stream_slot_cap.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_stream_teardown.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_talk.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_terminal_ack.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_token_file_robustness.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_token_refresh.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_turn_create_permission.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_upstream_compat.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_viewer_check_throttle.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_viewer_presence.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_viewer_present_push_mode.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_wait_or_event.py +0 -0
- {python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/tests/test_wake_readiness_retry.py +0 -0
{python_aidot_cameras-0.13.2/python_aidot_cameras.egg-info → python_aidot_cameras-0.14.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.14.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))
|
{python_aidot_cameras-0.13.2 → python_aidot_cameras-0.14.0}/aidot_cameras/camera/sdes_open.py
RENAMED
|
@@ -44,6 +44,12 @@ _SDES_BACKGROUND_TASKS: set = set()
|
|
|
44
44
|
_SDP_VIDEO_PTS = (96, 97, 98)
|
|
45
45
|
_SDP_AUDIO_PTS = (0, 8)
|
|
46
46
|
|
|
47
|
+
# Which payload type OUR ffmpeg SDP carries for each video codec. The template
|
|
48
|
+
# writes 96=H.264 and 97=H.265, so an answer that negotiates H.265 narrows to 97
|
|
49
|
+
# even when the camera numbers that codec differently on the wire - it is our own
|
|
50
|
+
# SDP being rewritten, not the camera's.
|
|
51
|
+
_SDP_VIDEO_PT_BY_CODEC = {"H264": 96, "H265": 97}
|
|
52
|
+
|
|
47
53
|
# How long to wait for the FIRST media of a session before launching the serve.
|
|
48
54
|
# Nothing useful can happen before then: the payload types are unknown, so the SDP
|
|
49
55
|
# cannot be narrowed, and ffmpeg would bind the wrong depacketizers. Sized to the
|
|
@@ -70,6 +76,50 @@ _PRE_LAUNCH_ANSWER_WAIT_S = 8.0
|
|
|
70
76
|
_AUDIO_PT_GRACE_S = 1.0
|
|
71
77
|
|
|
72
78
|
|
|
79
|
+
def video_pt_from_answer_sdp(sdp_text: str) -> Optional[int]:
|
|
80
|
+
"""Which video payload type to narrow to, taken from the camera's answer SDP.
|
|
81
|
+
|
|
82
|
+
This is the fallback for the case where no video RTP packet arrives before
|
|
83
|
+
the serve launches. Without it the ffmpeg SDP keeps advertising both codecs,
|
|
84
|
+
and :func:`narrow_sdp_payload_types` documents what that costs: ffmpeg binds
|
|
85
|
+
its depacketizer to the FIRST payload type and silently discards the rest,
|
|
86
|
+
and the RTSP-push ANNOUNCE carries a parameterless H.265 stream that go2rtc
|
|
87
|
+
rejects - so the publisher never attaches and every viewer gets a 404.
|
|
88
|
+
|
|
89
|
+
The answer already states which codec the camera agreed to send, so this is a
|
|
90
|
+
negotiated fact rather than a guess. Only the *first* video m-line is read,
|
|
91
|
+
and only ``a=rtpmap`` lines inside that section, so a payload number reused in
|
|
92
|
+
the audio section cannot leak into the answer.
|
|
93
|
+
|
|
94
|
+
Returns the payload type **our** template uses for that codec (96/97) - the
|
|
95
|
+
camera's own numbering need not agree - or None when the answer names no video
|
|
96
|
+
codec we write, which leaves the existing behaviour untouched.
|
|
97
|
+
"""
|
|
98
|
+
pts: list = []
|
|
99
|
+
rtpmap: dict = {}
|
|
100
|
+
section = None
|
|
101
|
+
for raw in (sdp_text or "").splitlines():
|
|
102
|
+
line = raw.strip()
|
|
103
|
+
if line.startswith("m="):
|
|
104
|
+
if section == "video":
|
|
105
|
+
break # the first video section has been read in full
|
|
106
|
+
section = "video" if line.startswith("m=video") else "other"
|
|
107
|
+
if section == "video":
|
|
108
|
+
# "m=video <port> <proto> <pt> [<pt> ...]"
|
|
109
|
+
pts = line.split()[3:]
|
|
110
|
+
continue
|
|
111
|
+
if section == "video" and line.startswith("a=rtpmap:"):
|
|
112
|
+
body = line[len("a=rtpmap:"):]
|
|
113
|
+
pt, _, enc = body.partition(" ")
|
|
114
|
+
if pt and enc:
|
|
115
|
+
rtpmap[pt.strip()] = enc.strip().split("/", 1)[0].upper()
|
|
116
|
+
for pt in pts:
|
|
117
|
+
codec = rtpmap.get(pt)
|
|
118
|
+
if codec in _SDP_VIDEO_PT_BY_CODEC:
|
|
119
|
+
return _SDP_VIDEO_PT_BY_CODEC[codec]
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
73
123
|
def narrow_sdp_payload_types(sdp_text: str, keep_video=None, keep_audio=None) -> str:
|
|
74
124
|
"""Rewrite an SDP to advertise a single payload type per media line.
|
|
75
125
|
|
|
@@ -2549,6 +2599,11 @@ class _SdesOpenMixin:
|
|
|
2549
2599
|
# bridge on the first video RTP packet so the ffmpeg SDP can be narrowed
|
|
2550
2600
|
# to the matching single codec before ffmpeg is launched.
|
|
2551
2601
|
_first_video_pt: list = [None]
|
|
2602
|
+
# Fallback for the above, filled from the camera's negotiated answer SDP
|
|
2603
|
+
# when the wait below expires without a single video packet. Kept in a
|
|
2604
|
+
# list for the same reason as _first_video_pt: the serve-restart path
|
|
2605
|
+
# reads it from a nested scope.
|
|
2606
|
+
_answer_video_pt: list = [None]
|
|
2552
2607
|
# Same for audio: the ffmpeg SDP advertises BOTH PCMU (0) and PCMA (8),
|
|
2553
2608
|
# and ffmpeg binds the depacketizer to the first one listed - so the line
|
|
2554
2609
|
# has to be narrowed to the payload type the camera actually sends, the
|
|
@@ -4159,7 +4214,29 @@ class _SdesOpenMixin:
|
|
|
4159
4214
|
# Which single payload type (if any) each line can be narrowed to. Audio
|
|
4160
4215
|
# is usable only when its type was actually observed; otherwise the "0 8"
|
|
4161
4216
|
# line cannot be narrowed.
|
|
4162
|
-
|
|
4217
|
+
if _vpt not in _SDP_VIDEO_PTS and answer_fut is not None and answer_fut.done():
|
|
4218
|
+
# No video packet arrived inside the window, but the camera's answer
|
|
4219
|
+
# still names the codec it agreed to send. Narrowing on that beats
|
|
4220
|
+
# leaving both codecs advertised: an unnarrowed video line makes
|
|
4221
|
+
# ffmpeg bind the wrong depacketizer, and makes the RTSP-push
|
|
4222
|
+
# ANNOUNCE carry a parameterless H.265 stream that go2rtc rejects
|
|
4223
|
+
# outright - no publisher, and every viewer sees a 404.
|
|
4224
|
+
try:
|
|
4225
|
+
if answer_fut.exception() is None:
|
|
4226
|
+
_answer_video_pt[0] = video_pt_from_answer_sdp(
|
|
4227
|
+
(answer_fut.result() or {}).get("sdp", "") or "")
|
|
4228
|
+
except Exception:
|
|
4229
|
+
_LOGGER.debug("camera %s: swallowed exception in %s",
|
|
4230
|
+
getattr(self, "device_id", "?"),
|
|
4231
|
+
'video_pt_from_answer_sdp', exc_info=True)
|
|
4232
|
+
if _answer_video_pt[0] is not None:
|
|
4233
|
+
_LOGGER.info(
|
|
4234
|
+
"camera %s: no video observed before the serve launched; "
|
|
4235
|
+
"narrowing the SDP to payload type %d from the camera's "
|
|
4236
|
+
"negotiated answer instead of advertising both codecs.",
|
|
4237
|
+
getattr(self, "device_id", "?"), _answer_video_pt[0],
|
|
4238
|
+
)
|
|
4239
|
+
_keep_v = int(_vpt) if _vpt in _SDP_VIDEO_PTS else _answer_video_pt[0]
|
|
4163
4240
|
_keep_a = int(_apt) if _apt in _SDP_AUDIO_PTS else None
|
|
4164
4241
|
if _serve_audio and _keep_a is None:
|
|
4165
4242
|
# No audio observed in the window, so the line cannot be narrowed and
|
|
@@ -4490,8 +4567,13 @@ class _SdesOpenMixin:
|
|
|
4490
4567
|
)
|
|
4491
4568
|
_new_sdp = narrow_sdp_payload_types(
|
|
4492
4569
|
_new_sdp,
|
|
4570
|
+
# Same answer-SDP fallback as the pre-launch narrowing:
|
|
4571
|
+
# this restart rebuilds the dual-codec template, so
|
|
4572
|
+
# without it a session that never saw a video packet
|
|
4573
|
+
# reproduces the unnarrowed SDP on every watchdog cycle.
|
|
4493
4574
|
keep_video=(_first_video_pt[0]
|
|
4494
|
-
if _first_video_pt[0] in _SDP_VIDEO_PTS
|
|
4575
|
+
if _first_video_pt[0] in _SDP_VIDEO_PTS
|
|
4576
|
+
else _answer_video_pt[0]),
|
|
4495
4577
|
keep_audio=(_first_audio_pt[0]
|
|
4496
4578
|
if _first_audio_pt[0] in (0, 8) else None),
|
|
4497
4579
|
)
|