Desmond-Dong commited on
Commit
edbcbf8
·
1 Parent(s): cfd357d

v0.2.6: Add thread-safe ReSpeaker USB access to prevent daemon deadlock

Browse files
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.4"
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.4"
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 = 0.1 # 100ms 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 None
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 None
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 None
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
- respeaker = self._get_respeaker()
1009
- if respeaker is None:
1010
- return getattr(self, '_agc_enabled', False)
1011
- try:
1012
- result = respeaker.read("PP_AGCONOFF")
1013
- if result is not None:
1014
- self._agc_enabled = bool(result[1])
1015
- return self._agc_enabled
1016
- except Exception as e:
1017
- logger.debug(f"Error getting AGC status: {e}")
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
- respeaker = self._get_respeaker()
1024
- if respeaker is None:
1025
- return
1026
- try:
1027
- respeaker.write("PP_AGCONOFF", [1 if enabled else 0])
1028
- logger.info(f"AGC {'enabled' if enabled else 'disabled'}")
1029
- except Exception as e:
1030
- logger.error(f"Error setting AGC status: {e}")
1031
 
1032
  def get_agc_max_gain(self) -> float:
1033
  """Get AGC maximum gain in dB."""
1034
- respeaker = self._get_respeaker()
1035
- if respeaker is None:
1036
- return getattr(self, '_agc_max_gain', 15.0)
1037
- try:
1038
- result = respeaker.read("PP_AGCMAXGAIN")
1039
- if result is not None:
1040
- self._agc_max_gain = float(result[0])
1041
- return self._agc_max_gain
1042
- except Exception as e:
1043
- logger.debug(f"Error getting AGC max gain: {e}")
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
- respeaker = self._get_respeaker()
1051
- if respeaker is None:
1052
- return
1053
- try:
1054
- respeaker.write("PP_AGCMAXGAIN", [gain])
1055
- logger.info(f"AGC max gain set to {gain} dB")
1056
- except Exception as e:
1057
- logger.error(f"Error setting AGC max gain: {e}")
1058
 
1059
  def get_noise_suppression(self) -> float:
1060
  """Get noise suppression level (0-100%)."""
1061
- respeaker = self._get_respeaker()
1062
- if respeaker is None:
1063
- return getattr(self, '_noise_suppression', 50.0)
1064
- try:
1065
- result = respeaker.read("PP_MIN_NS")
1066
- if result is not None:
1067
- # PP_MIN_NS is typically a float value, convert to percentage
1068
- # Lower values = more suppression
1069
- self._noise_suppression = max(0.0, min(100.0, (1.0 - result[0]) * 100.0))
1070
- return self._noise_suppression
1071
- except Exception as e:
1072
- logger.debug(f"Error getting noise suppression: {e}")
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
- respeaker = self._get_respeaker()
1080
- if respeaker is None:
1081
- return
1082
- try:
1083
- # Convert percentage to PP_MIN_NS value (inverted)
1084
- value = 1.0 - (level / 100.0)
1085
- respeaker.write("PP_MIN_NS", [value])
1086
- logger.info(f"Noise suppression set to {level}%")
1087
- except Exception as e:
1088
- logger.error(f"Error setting noise suppression: {e}")
1089
 
1090
  def get_echo_cancellation_converged(self) -> bool:
1091
  """Check if echo cancellation has converged."""
1092
- respeaker = self._get_respeaker()
1093
- if respeaker is None:
1094
- return False
1095
- try:
1096
- result = respeaker.read("AEC_AECCONVERGED")
1097
- if result is not None:
1098
- return bool(result[1])
1099
- except Exception as e:
1100
- logger.debug(f"Error getting AEC converged status: {e}")
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 ==========