Commit ·
edbcbf8
1
Parent(s): cfd357d
v0.2.6: Add thread-safe ReSpeaker USB access to prevent daemon deadlock
Browse files- pyproject.toml +1 -1
- reachy_mini_ha_voice/__init__.py +1 -1
- reachy_mini_ha_voice/reachy_controller.py +130 -78
pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "reachy_mini_ha_voice"
|
| 7 |
-
version = "0.2.
|
| 8 |
description = "Home Assistant Voice Assistant for Reachy Mini"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "reachy_mini_ha_voice"
|
| 7 |
+
version = "0.2.6"
|
| 8 |
description = "Home Assistant Voice Assistant for Reachy Mini"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.10"
|
reachy_mini_ha_voice/__init__.py
CHANGED
|
@@ -11,7 +11,7 @@ Key features:
|
|
| 11 |
- Reachy Mini motion control integration
|
| 12 |
"""
|
| 13 |
|
| 14 |
-
__version__ = "0.2.
|
| 15 |
__author__ = "Desmond Dong"
|
| 16 |
|
| 17 |
# Don't import main module here to avoid runpy warning
|
|
|
|
| 11 |
- Reachy Mini motion control integration
|
| 12 |
"""
|
| 13 |
|
| 14 |
+
__version__ = "0.2.6"
|
| 15 |
__author__ = "Desmond Dong"
|
| 16 |
|
| 17 |
# Don't import main module here to avoid runpy warning
|
reachy_mini_ha_voice/reachy_controller.py
CHANGED
|
@@ -33,11 +33,21 @@ class ReachyController:
|
|
| 33 |
self._speaker_volume = 100 # Default volume
|
| 34 |
|
| 35 |
# State caching to reduce daemon load
|
|
|
|
|
|
|
| 36 |
self._state_cache: Dict[str, Any] = {}
|
| 37 |
-
self._cache_ttl =
|
| 38 |
self._last_status_query = 0.0
|
| 39 |
self._last_pose_query = 0.0
|
| 40 |
self._last_joints_query = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
@property
|
| 43 |
def is_available(self) -> bool:
|
|
@@ -55,6 +65,13 @@ class ReachyController:
|
|
| 55 |
if not self.is_available:
|
| 56 |
return None
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
try:
|
| 59 |
status = self.reachy.client.get_status(wait=False)
|
| 60 |
self._state_cache['status'] = status
|
|
@@ -62,7 +79,7 @@ class ReachyController:
|
|
| 62 |
return status
|
| 63 |
except Exception as e:
|
| 64 |
logger.error(f"Error getting status: {e}")
|
| 65 |
-
return
|
| 66 |
|
| 67 |
def get_daemon_state(self) -> str:
|
| 68 |
"""Get daemon state with caching."""
|
|
@@ -312,6 +329,12 @@ class ReachyController:
|
|
| 312 |
if not self.is_available:
|
| 313 |
return None
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
try:
|
| 316 |
pose = self.reachy.get_current_head_pose()
|
| 317 |
self._state_cache['head_pose'] = pose
|
|
@@ -319,7 +342,7 @@ class ReachyController:
|
|
| 319 |
return pose
|
| 320 |
except Exception as e:
|
| 321 |
logger.error(f"Error getting head pose: {e}")
|
| 322 |
-
return
|
| 323 |
|
| 324 |
def _get_cached_joint_positions(self) -> Optional[tuple]:
|
| 325 |
"""Get cached joint positions to reduce query frequency."""
|
|
@@ -330,6 +353,12 @@ class ReachyController:
|
|
| 330 |
if not self.is_available:
|
| 331 |
return None
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
try:
|
| 334 |
joints = self.reachy.get_current_joint_positions()
|
| 335 |
self._state_cache['joint_positions'] = joints
|
|
@@ -337,7 +366,7 @@ class ReachyController:
|
|
| 337 |
return joints
|
| 338 |
except Exception as e:
|
| 339 |
logger.error(f"Error getting joint positions: {e}")
|
| 340 |
-
return
|
| 341 |
|
| 342 |
def _extract_pose_from_matrix(self, pose_matrix: np.ndarray) -> tuple:
|
| 343 |
"""
|
|
@@ -845,24 +874,47 @@ class ReachyController:
|
|
| 845 |
# ========== Phase 11: LED Control (via local SDK) ==========
|
| 846 |
|
| 847 |
def _get_respeaker(self):
|
| 848 |
-
"""Get ReSpeaker device from media manager.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 849 |
if not self.is_available:
|
| 850 |
logger.debug("ReSpeaker not available: robot not connected")
|
| 851 |
-
return None
|
| 852 |
try:
|
| 853 |
if not self.reachy.media:
|
| 854 |
logger.debug("ReSpeaker not available: media manager is None")
|
| 855 |
-
return None
|
| 856 |
if not self.reachy.media.audio:
|
| 857 |
logger.debug("ReSpeaker not available: audio is None")
|
| 858 |
-
return None
|
| 859 |
respeaker = self.reachy.media.audio._respeaker
|
| 860 |
if respeaker is None:
|
| 861 |
logger.debug("ReSpeaker not available: _respeaker is None (USB device not found)")
|
| 862 |
-
return respeaker
|
| 863 |
except Exception as e:
|
| 864 |
logger.debug(f"ReSpeaker not available: {e}")
|
| 865 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 866 |
|
| 867 |
# ========== Phase 11: LED Control (DISABLED - LEDs are inside the robot and not visible) ==========
|
| 868 |
# According to PROJECT_PLAN.md principle 8: "LED都被隐藏在了机器人内部,所有的LED控制全部都忽略"
|
|
@@ -1001,103 +1053,103 @@ class ReachyController:
|
|
| 1001 |
# except Exception as e:
|
| 1002 |
# logger.error(f"Error setting LED color: {e}")
|
| 1003 |
|
| 1004 |
-
# ========== Phase 12: Audio Processing (via local SDK) ==========
|
| 1005 |
|
| 1006 |
def get_agc_enabled(self) -> bool:
|
| 1007 |
"""Get AGC (Automatic Gain Control) enabled status."""
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
return getattr(self, '_agc_enabled', False)
|
| 1019 |
|
| 1020 |
def set_agc_enabled(self, enabled: bool) -> None:
|
| 1021 |
"""Set AGC (Automatic Gain Control) enabled status."""
|
| 1022 |
self._agc_enabled = enabled
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
|
| 1032 |
def get_agc_max_gain(self) -> float:
|
| 1033 |
"""Get AGC maximum gain in dB."""
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
return getattr(self, '_agc_max_gain', 15.0)
|
| 1045 |
|
| 1046 |
def set_agc_max_gain(self, gain: float) -> None:
|
| 1047 |
"""Set AGC maximum gain in dB."""
|
| 1048 |
gain = max(0.0, min(30.0, gain))
|
| 1049 |
self._agc_max_gain = gain
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
|
| 1059 |
def get_noise_suppression(self) -> float:
|
| 1060 |
"""Get noise suppression level (0-100%)."""
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
return getattr(self, '_noise_suppression', 50.0)
|
| 1074 |
|
| 1075 |
def set_noise_suppression(self, level: float) -> None:
|
| 1076 |
"""Set noise suppression level (0-100%)."""
|
| 1077 |
level = max(0.0, min(100.0, level))
|
| 1078 |
self._noise_suppression = level
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
|
| 1090 |
def get_echo_cancellation_converged(self) -> bool:
|
| 1091 |
"""Check if echo cancellation has converged."""
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
return False
|
| 1102 |
|
| 1103 |
# ========== Phase 13: Robot Joints ==========
|
|
|
|
| 33 |
self._speaker_volume = 100 # Default volume
|
| 34 |
|
| 35 |
# State caching to reduce daemon load
|
| 36 |
+
# Increased TTL to 1 second to prevent overwhelming the daemon
|
| 37 |
+
# when Home Assistant subscribes to all entities at once
|
| 38 |
self._state_cache: Dict[str, Any] = {}
|
| 39 |
+
self._cache_ttl = 1.0 # 1 second cache TTL (was 100ms)
|
| 40 |
self._last_status_query = 0.0
|
| 41 |
self._last_pose_query = 0.0
|
| 42 |
self._last_joints_query = 0.0
|
| 43 |
+
|
| 44 |
+
# Request throttling to prevent daemon overload
|
| 45 |
+
self._min_request_interval = 0.1 # Minimum 100ms between SDK requests
|
| 46 |
+
self._last_sdk_request = 0.0
|
| 47 |
+
self._request_lock = __import__('threading').Lock()
|
| 48 |
+
|
| 49 |
+
# Thread lock for ReSpeaker USB access to prevent conflicts with GStreamer audio pipeline
|
| 50 |
+
self._respeaker_lock = __import__('threading').Lock()
|
| 51 |
|
| 52 |
@property
|
| 53 |
def is_available(self) -> bool:
|
|
|
|
| 65 |
if not self.is_available:
|
| 66 |
return None
|
| 67 |
|
| 68 |
+
# Throttle SDK requests to prevent daemon overload
|
| 69 |
+
with self._request_lock:
|
| 70 |
+
if now - self._last_sdk_request < self._min_request_interval:
|
| 71 |
+
# Return cached value if we're requesting too fast
|
| 72 |
+
return self._state_cache.get('status')
|
| 73 |
+
self._last_sdk_request = now
|
| 74 |
+
|
| 75 |
try:
|
| 76 |
status = self.reachy.client.get_status(wait=False)
|
| 77 |
self._state_cache['status'] = status
|
|
|
|
| 79 |
return status
|
| 80 |
except Exception as e:
|
| 81 |
logger.error(f"Error getting status: {e}")
|
| 82 |
+
return self._state_cache.get('status') # Return stale cache on error
|
| 83 |
|
| 84 |
def get_daemon_state(self) -> str:
|
| 85 |
"""Get daemon state with caching."""
|
|
|
|
| 329 |
if not self.is_available:
|
| 330 |
return None
|
| 331 |
|
| 332 |
+
# Throttle SDK requests to prevent daemon overload
|
| 333 |
+
with self._request_lock:
|
| 334 |
+
if now - self._last_sdk_request < self._min_request_interval:
|
| 335 |
+
return self._state_cache.get('head_pose')
|
| 336 |
+
self._last_sdk_request = now
|
| 337 |
+
|
| 338 |
try:
|
| 339 |
pose = self.reachy.get_current_head_pose()
|
| 340 |
self._state_cache['head_pose'] = pose
|
|
|
|
| 342 |
return pose
|
| 343 |
except Exception as e:
|
| 344 |
logger.error(f"Error getting head pose: {e}")
|
| 345 |
+
return self._state_cache.get('head_pose') # Return stale cache on error
|
| 346 |
|
| 347 |
def _get_cached_joint_positions(self) -> Optional[tuple]:
|
| 348 |
"""Get cached joint positions to reduce query frequency."""
|
|
|
|
| 353 |
if not self.is_available:
|
| 354 |
return None
|
| 355 |
|
| 356 |
+
# Throttle SDK requests to prevent daemon overload
|
| 357 |
+
with self._request_lock:
|
| 358 |
+
if now - self._last_sdk_request < self._min_request_interval:
|
| 359 |
+
return self._state_cache.get('joint_positions')
|
| 360 |
+
self._last_sdk_request = now
|
| 361 |
+
|
| 362 |
try:
|
| 363 |
joints = self.reachy.get_current_joint_positions()
|
| 364 |
self._state_cache['joint_positions'] = joints
|
|
|
|
| 366 |
return joints
|
| 367 |
except Exception as e:
|
| 368 |
logger.error(f"Error getting joint positions: {e}")
|
| 369 |
+
return self._state_cache.get('joint_positions') # Return stale cache on error
|
| 370 |
|
| 371 |
def _extract_pose_from_matrix(self, pose_matrix: np.ndarray) -> tuple:
|
| 372 |
"""
|
|
|
|
| 874 |
# ========== Phase 11: LED Control (via local SDK) ==========
|
| 875 |
|
| 876 |
def _get_respeaker(self):
|
| 877 |
+
"""Get ReSpeaker device from media manager with thread-safe access.
|
| 878 |
+
|
| 879 |
+
Returns a context manager that holds the lock during ReSpeaker operations.
|
| 880 |
+
Usage:
|
| 881 |
+
with self._get_respeaker() as respeaker:
|
| 882 |
+
if respeaker:
|
| 883 |
+
respeaker.read("...")
|
| 884 |
+
"""
|
| 885 |
if not self.is_available:
|
| 886 |
logger.debug("ReSpeaker not available: robot not connected")
|
| 887 |
+
return _ReSpeakerContext(None, self._respeaker_lock)
|
| 888 |
try:
|
| 889 |
if not self.reachy.media:
|
| 890 |
logger.debug("ReSpeaker not available: media manager is None")
|
| 891 |
+
return _ReSpeakerContext(None, self._respeaker_lock)
|
| 892 |
if not self.reachy.media.audio:
|
| 893 |
logger.debug("ReSpeaker not available: audio is None")
|
| 894 |
+
return _ReSpeakerContext(None, self._respeaker_lock)
|
| 895 |
respeaker = self.reachy.media.audio._respeaker
|
| 896 |
if respeaker is None:
|
| 897 |
logger.debug("ReSpeaker not available: _respeaker is None (USB device not found)")
|
| 898 |
+
return _ReSpeakerContext(respeaker, self._respeaker_lock)
|
| 899 |
except Exception as e:
|
| 900 |
logger.debug(f"ReSpeaker not available: {e}")
|
| 901 |
+
return _ReSpeakerContext(None, self._respeaker_lock)
|
| 902 |
+
|
| 903 |
+
|
| 904 |
+
class _ReSpeakerContext:
|
| 905 |
+
"""Context manager for thread-safe ReSpeaker access."""
|
| 906 |
+
|
| 907 |
+
def __init__(self, respeaker, lock):
|
| 908 |
+
self._respeaker = respeaker
|
| 909 |
+
self._lock = lock
|
| 910 |
+
|
| 911 |
+
def __enter__(self):
|
| 912 |
+
self._lock.acquire()
|
| 913 |
+
return self._respeaker
|
| 914 |
+
|
| 915 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 916 |
+
self._lock.release()
|
| 917 |
+
return False
|
| 918 |
|
| 919 |
# ========== Phase 11: LED Control (DISABLED - LEDs are inside the robot and not visible) ==========
|
| 920 |
# According to PROJECT_PLAN.md principle 8: "LED都被隐藏在了机器人内部,所有的LED控制全部都忽略"
|
|
|
|
| 1053 |
# except Exception as e:
|
| 1054 |
# logger.error(f"Error setting LED color: {e}")
|
| 1055 |
|
| 1056 |
+
# ========== Phase 12: Audio Processing (via local SDK with thread-safe access) ==========
|
| 1057 |
|
| 1058 |
def get_agc_enabled(self) -> bool:
|
| 1059 |
"""Get AGC (Automatic Gain Control) enabled status."""
|
| 1060 |
+
with self._get_respeaker() as respeaker:
|
| 1061 |
+
if respeaker is None:
|
| 1062 |
+
return getattr(self, '_agc_enabled', False)
|
| 1063 |
+
try:
|
| 1064 |
+
result = respeaker.read("PP_AGCONOFF")
|
| 1065 |
+
if result is not None:
|
| 1066 |
+
self._agc_enabled = bool(result[1])
|
| 1067 |
+
return self._agc_enabled
|
| 1068 |
+
except Exception as e:
|
| 1069 |
+
logger.debug(f"Error getting AGC status: {e}")
|
| 1070 |
return getattr(self, '_agc_enabled', False)
|
| 1071 |
|
| 1072 |
def set_agc_enabled(self, enabled: bool) -> None:
|
| 1073 |
"""Set AGC (Automatic Gain Control) enabled status."""
|
| 1074 |
self._agc_enabled = enabled
|
| 1075 |
+
with self._get_respeaker() as respeaker:
|
| 1076 |
+
if respeaker is None:
|
| 1077 |
+
return
|
| 1078 |
+
try:
|
| 1079 |
+
respeaker.write("PP_AGCONOFF", [1 if enabled else 0])
|
| 1080 |
+
logger.info(f"AGC {'enabled' if enabled else 'disabled'}")
|
| 1081 |
+
except Exception as e:
|
| 1082 |
+
logger.error(f"Error setting AGC status: {e}")
|
| 1083 |
|
| 1084 |
def get_agc_max_gain(self) -> float:
|
| 1085 |
"""Get AGC maximum gain in dB."""
|
| 1086 |
+
with self._get_respeaker() as respeaker:
|
| 1087 |
+
if respeaker is None:
|
| 1088 |
+
return getattr(self, '_agc_max_gain', 15.0)
|
| 1089 |
+
try:
|
| 1090 |
+
result = respeaker.read("PP_AGCMAXGAIN")
|
| 1091 |
+
if result is not None:
|
| 1092 |
+
self._agc_max_gain = float(result[0])
|
| 1093 |
+
return self._agc_max_gain
|
| 1094 |
+
except Exception as e:
|
| 1095 |
+
logger.debug(f"Error getting AGC max gain: {e}")
|
| 1096 |
return getattr(self, '_agc_max_gain', 15.0)
|
| 1097 |
|
| 1098 |
def set_agc_max_gain(self, gain: float) -> None:
|
| 1099 |
"""Set AGC maximum gain in dB."""
|
| 1100 |
gain = max(0.0, min(30.0, gain))
|
| 1101 |
self._agc_max_gain = gain
|
| 1102 |
+
with self._get_respeaker() as respeaker:
|
| 1103 |
+
if respeaker is None:
|
| 1104 |
+
return
|
| 1105 |
+
try:
|
| 1106 |
+
respeaker.write("PP_AGCMAXGAIN", [gain])
|
| 1107 |
+
logger.info(f"AGC max gain set to {gain} dB")
|
| 1108 |
+
except Exception as e:
|
| 1109 |
+
logger.error(f"Error setting AGC max gain: {e}")
|
| 1110 |
|
| 1111 |
def get_noise_suppression(self) -> float:
|
| 1112 |
"""Get noise suppression level (0-100%)."""
|
| 1113 |
+
with self._get_respeaker() as respeaker:
|
| 1114 |
+
if respeaker is None:
|
| 1115 |
+
return getattr(self, '_noise_suppression', 50.0)
|
| 1116 |
+
try:
|
| 1117 |
+
result = respeaker.read("PP_MIN_NS")
|
| 1118 |
+
if result is not None:
|
| 1119 |
+
# PP_MIN_NS is typically a float value, convert to percentage
|
| 1120 |
+
# Lower values = more suppression
|
| 1121 |
+
self._noise_suppression = max(0.0, min(100.0, (1.0 - result[0]) * 100.0))
|
| 1122 |
+
return self._noise_suppression
|
| 1123 |
+
except Exception as e:
|
| 1124 |
+
logger.debug(f"Error getting noise suppression: {e}")
|
| 1125 |
return getattr(self, '_noise_suppression', 50.0)
|
| 1126 |
|
| 1127 |
def set_noise_suppression(self, level: float) -> None:
|
| 1128 |
"""Set noise suppression level (0-100%)."""
|
| 1129 |
level = max(0.0, min(100.0, level))
|
| 1130 |
self._noise_suppression = level
|
| 1131 |
+
with self._get_respeaker() as respeaker:
|
| 1132 |
+
if respeaker is None:
|
| 1133 |
+
return
|
| 1134 |
+
try:
|
| 1135 |
+
# Convert percentage to PP_MIN_NS value (inverted)
|
| 1136 |
+
value = 1.0 - (level / 100.0)
|
| 1137 |
+
respeaker.write("PP_MIN_NS", [value])
|
| 1138 |
+
logger.info(f"Noise suppression set to {level}%")
|
| 1139 |
+
except Exception as e:
|
| 1140 |
+
logger.error(f"Error setting noise suppression: {e}")
|
| 1141 |
|
| 1142 |
def get_echo_cancellation_converged(self) -> bool:
|
| 1143 |
"""Check if echo cancellation has converged."""
|
| 1144 |
+
with self._get_respeaker() as respeaker:
|
| 1145 |
+
if respeaker is None:
|
| 1146 |
+
return False
|
| 1147 |
+
try:
|
| 1148 |
+
result = respeaker.read("AEC_AECCONVERGED")
|
| 1149 |
+
if result is not None:
|
| 1150 |
+
return bool(result[1])
|
| 1151 |
+
except Exception as e:
|
| 1152 |
+
logger.debug(f"Error getting AEC converged status: {e}")
|
| 1153 |
return False
|
| 1154 |
|
| 1155 |
# ========== Phase 13: Robot Joints ==========
|