ball_tracking_manager.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. """
  2. Ball Tracking LED Manager
  3. Tracks the ball bearing's position and updates LEDs in real-time to follow its movement.
  4. """
  5. import asyncio
  6. import time
  7. import logging
  8. from collections import deque
  9. from typing import Optional, Tuple, Dict
  10. from .dw_led_controller import DWLEDController
  11. logger = logging.getLogger(__name__)
  12. class BallTrackingManager:
  13. """Manages real-time LED tracking of ball bearing position"""
  14. def __init__(self, led_controller: DWLEDController, num_leds: int, config: Dict):
  15. """
  16. Initialize ball tracking manager
  17. Args:
  18. led_controller: DWLEDController instance (kept for compatibility, not used for rendering)
  19. num_leds: Number of LEDs in the strip
  20. config: Configuration dict with keys:
  21. - led_offset: LED index offset (0 to num_leds-1)
  22. - reversed: Reverse LED direction (bool)
  23. - spread: Number of adjacent LEDs to light (1-10)
  24. - lookback: Number of coordinates to look back (0-15)
  25. - brightness: LED brightness 0-100
  26. - color: Hex color string (e.g., "#ffffff")
  27. - trail_enabled: Enable fade trail (bool)
  28. - trail_length: Trail length in LEDs (1-20)
  29. """
  30. self.led_controller = led_controller # Kept for backward compatibility
  31. self.num_leds = num_leds
  32. self.config = config
  33. # Coordinate history buffer (max 15 coordinates)
  34. self.position_buffer = deque(maxlen=15)
  35. # Tracking state
  36. self._active = False
  37. self._update_task = None
  38. self._last_led_index = None
  39. logger.info(f"BallTrackingManager initialized with {num_leds} LEDs")
  40. def start(self):
  41. """Start ball tracking"""
  42. if self._active:
  43. logger.warning("Ball tracking already active")
  44. return
  45. self._active = True
  46. logger.info("Ball tracking started")
  47. def stop(self):
  48. """Stop ball tracking"""
  49. if not self._active:
  50. return
  51. self._active = False
  52. self.position_buffer.clear()
  53. self._last_led_index = None
  54. logger.info("Ball tracking stopped")
  55. def update_position(self, theta: float, rho: float):
  56. """
  57. Update ball position (called from pattern execution)
  58. Args:
  59. theta: Angular position in degrees (0-360)
  60. rho: Radial distance (0.0-1.0)
  61. """
  62. if not self._active:
  63. return
  64. # Add to buffer
  65. self.position_buffer.append((theta, rho, time.time()))
  66. # Trigger LED update
  67. self._update_leds()
  68. def _update_leds(self):
  69. """Update LED tracking state (rendering is done by effect loop)"""
  70. if not self._active:
  71. return
  72. # Get position to track (with lookback)
  73. position = self._get_tracked_position()
  74. if position is None:
  75. return
  76. theta, rho, _ = position
  77. # Calculate LED index
  78. led_index = self._theta_to_led(theta)
  79. # Store the LED index (effect will read this)
  80. self._last_led_index = led_index
  81. def _get_tracked_position(self) -> Optional[Tuple[float, float, float]]:
  82. """Get position to track (accounting for lookback delay)"""
  83. lookback = self.config.get("lookback", 0)
  84. if len(self.position_buffer) == 0:
  85. return None
  86. # Clamp lookback to buffer size
  87. lookback = min(lookback, len(self.position_buffer) - 1)
  88. lookback = max(0, lookback)
  89. # Get position from buffer
  90. # Index -1 = most recent, -2 = one back, etc.
  91. index = -(lookback + 1)
  92. return self.position_buffer[index]
  93. def _theta_to_led(self, theta: float) -> int:
  94. """
  95. Convert theta angle to LED index
  96. Args:
  97. theta: Angle in degrees (0-360)
  98. Returns:
  99. LED index (0 to num_leds-1)
  100. """
  101. # Normalize theta to 0-360
  102. theta = theta % 360
  103. if theta < 0:
  104. theta += 360
  105. # Calculate LED index (0° = LED 0 before offset)
  106. led_index = int((theta / 360.0) * self.num_leds)
  107. original_index = led_index
  108. # Apply user-defined offset
  109. offset = self.config.get("led_offset", 0)
  110. led_index = (led_index + offset) % self.num_leds
  111. # Reverse direction if needed
  112. is_reversed = self.config.get("reversed", False)
  113. if is_reversed:
  114. led_index_before_reverse = led_index
  115. led_index = (self.num_leds - led_index) % self.num_leds
  116. logger.debug(f"Theta={theta:.1f}° -> LED {original_index} + offset {offset} = {led_index_before_reverse} -> REVERSED to {led_index}")
  117. else:
  118. logger.debug(f"Theta={theta:.1f}° -> LED {original_index} + offset {offset} = {led_index}")
  119. return led_index
  120. def get_tracking_data(self) -> Optional[Dict]:
  121. """
  122. Get current tracking data for effect rendering
  123. Returns:
  124. Dictionary with led_index, spread, brightness, color
  125. or None if no tracking data available
  126. """
  127. if self._last_led_index is None:
  128. return None
  129. # Get configuration
  130. spread = self.config.get("spread", 3)
  131. brightness = self.config.get("brightness", 50) / 100.0
  132. color_hex = self.config.get("color", "#ffffff")
  133. # Convert hex color to RGB tuple
  134. color_hex = color_hex.lstrip('#')
  135. r = int(color_hex[0:2], 16)
  136. g = int(color_hex[2:4], 16)
  137. b = int(color_hex[4:6], 16)
  138. return {
  139. 'led_index': self._last_led_index,
  140. 'spread': spread,
  141. 'brightness': brightness,
  142. 'color': (r, g, b)
  143. }
  144. def update_config(self, config: Dict):
  145. """Update configuration at runtime"""
  146. self.config.update(config)
  147. logger.info(f"Ball tracking config updated: {config}")
  148. logger.info(f"Current reversed setting: {self.config.get('reversed', False)}")
  149. def get_status(self) -> Dict:
  150. """Get current tracking status"""
  151. return {
  152. "active": self._active,
  153. "buffer_size": len(self.position_buffer),
  154. "last_led_index": self._last_led_index,
  155. "config": self.config
  156. }