arbitration_manager.py 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049
  1. """
  2. Arbitration Manager for Trixy Satellite Selection
  3. This module provides the main ArbitrationManager class that implements the complete
  4. arbitration system for resolving wakeword conflicts between multiple satellites.
  5. The ArbitrationManager implements the workflow specified in CLAUDE.md:
  6. 1. Multiple satellites detect wakeword simultaneously
  7. 2. Server waits 1 second to collect all wakeword reports
  8. 3. Server selects satellite with highest volume (or other algorithm)
  9. 4. Selected satellite gets conversation session
  10. 5. Non-selected satellites are notified to ignore
  11. Key Features:
  12. - Event-driven integration with Trixy event system
  13. - 1-second collection window for wakeword reports
  14. - Multiple arbitration algorithms (volume-based primary)
  15. - Thread-safe concurrent wakeword handling
  16. - Integration with satellite and conversation managers
  17. - Comprehensive history tracking and analytics
  18. - Plugin support for custom arbitration algorithms
  19. - Real-time monitoring and debugging capabilities
  20. Usage:
  21. from trixy_core.arbitration import ArbitrationManager, ArbitrationConfig
  22. # Create arbitration manager
  23. config = ArbitrationConfig()
  24. arbitration_manager = ArbitrationManager(application, config)
  25. # Start the manager (registers event handlers)
  26. arbitration_manager.start()
  27. # The manager automatically handles wakeword_received events
  28. # and coordinates with satellite and conversation managers
  29. """
  30. import asyncio
  31. import time
  32. import threading
  33. from typing import Dict, List, Optional, Any, Callable, Set, Union
  34. from dataclasses import dataclass, field
  35. from datetime import datetime, timedelta
  36. from concurrent.futures import ThreadPoolExecutor, Future
  37. import uuid
  38. from trixy_core.events import (
  39. EventHandler, TrixyEvent, WakewordReceivedEventData,
  40. SatelliteInfo, SpeakerInfo, EventType,
  41. ArbitrationStartedEventData, ArbitrationCompletedEventData,
  42. ArbitrationCancelledEventData, ArbitrationTimedOutEventData,
  43. ArbitrationReportAddedEventData, SatelliteSelectedEventData,
  44. SatelliteIgnoredEventData
  45. )
  46. from .arbitration_config import (
  47. ArbitrationConfig, ArbitrationAlgorithm, ArbitrationMode,
  48. ArbitrationConfigError, create_default_config
  49. )
  50. from .arbitration_algorithms import (
  51. ArbitrationAlgorithms, WakewordReport, SelectionResult,
  52. create_test_reports
  53. )
  54. from .arbitration_session import (
  55. ArbitrationSession, ArbitrationSessionManager, SessionState,
  56. create_session_manager
  57. )
  58. def pprint(message: str) -> None:
  59. """Arbitration manager logging function."""
  60. print(f"[ARBITRATION_MANAGER] {message}")
  61. @dataclass
  62. class ArbitrationStats:
  63. """Statistics for arbitration manager operations."""
  64. total_arbitrations: int = 0
  65. successful_arbitrations: int = 0
  66. failed_arbitrations: int = 0
  67. cancelled_arbitrations: int = 0
  68. timed_out_arbitrations: int = 0
  69. total_wakeword_reports: int = 0
  70. unique_satellites_involved: Set[str] = field(default_factory=set)
  71. average_collection_time: float = 0.0
  72. average_arbitration_time: float = 0.0
  73. algorithm_usage: Dict[str, int] = field(default_factory=dict)
  74. satellite_selection_count: Dict[str, int] = field(default_factory=dict)
  75. room_selection_count: Dict[str, int] = field(default_factory=dict)
  76. def update_from_session(self, session: ArbitrationSession) -> None:
  77. """Update statistics from completed session."""
  78. if session.state == SessionState.COMPLETED:
  79. self.successful_arbitrations += 1
  80. if session.selected_satellite_id:
  81. self.satellite_selection_count[session.selected_satellite_id] = (
  82. self.satellite_selection_count.get(session.selected_satellite_id, 0) + 1
  83. )
  84. if session.selection_result:
  85. algorithm = session.selection_result.algorithm_used.value
  86. self.algorithm_usage[algorithm] = self.algorithm_usage.get(algorithm, 0) + 1
  87. elif session.state == SessionState.CANCELLED:
  88. self.cancelled_arbitrations += 1
  89. elif session.state == SessionState.TIMED_OUT:
  90. self.timed_out_arbitrations += 1
  91. else:
  92. self.failed_arbitrations += 1
  93. self.total_arbitrations += 1
  94. self.total_wakeword_reports += len(session.wakeword_reports)
  95. for report in session.wakeword_reports:
  96. self.unique_satellites_involved.add(report.satellite_id)
  97. if report.room_id:
  98. self.room_selection_count[report.room_id] = (
  99. self.room_selection_count.get(report.room_id, 0) + 1
  100. )
  101. # Update timing averages
  102. if session.timing.get_collection_duration():
  103. self.average_collection_time = self._update_average(
  104. self.average_collection_time,
  105. session.timing.get_collection_duration(),
  106. self.total_arbitrations
  107. )
  108. if session.timing.get_arbitration_duration():
  109. self.average_arbitration_time = self._update_average(
  110. self.average_arbitration_time,
  111. session.timing.get_arbitration_duration(),
  112. self.total_arbitrations
  113. )
  114. def _update_average(self, current_avg: float, new_value: float, count: int) -> float:
  115. """Update running average."""
  116. if count <= 1:
  117. return new_value
  118. return ((current_avg * (count - 1)) + new_value) / count
  119. def get_success_rate(self) -> float:
  120. """Get arbitration success rate."""
  121. if self.total_arbitrations == 0:
  122. return 0.0
  123. return self.successful_arbitrations / self.total_arbitrations
  124. def to_dict(self) -> Dict[str, Any]:
  125. """Convert statistics to dictionary."""
  126. return {
  127. 'total_arbitrations': self.total_arbitrations,
  128. 'successful_arbitrations': self.successful_arbitrations,
  129. 'failed_arbitrations': self.failed_arbitrations,
  130. 'cancelled_arbitrations': self.cancelled_arbitrations,
  131. 'timed_out_arbitrations': self.timed_out_arbitrations,
  132. 'success_rate': self.get_success_rate(),
  133. 'total_wakeword_reports': self.total_wakeword_reports,
  134. 'unique_satellites_count': len(self.unique_satellites_involved),
  135. 'average_collection_time': self.average_collection_time,
  136. 'average_arbitration_time': self.average_arbitration_time,
  137. 'algorithm_usage': dict(self.algorithm_usage),
  138. 'satellite_selection_count': dict(self.satellite_selection_count),
  139. 'room_selection_count': dict(self.room_selection_count),
  140. }
  141. class ArbitrationManagerError(Exception):
  142. """Base exception for arbitration manager errors."""
  143. pass
  144. class ArbitrationManagerNotStartedError(ArbitrationManagerError):
  145. """Raised when manager operation requires started state."""
  146. pass
  147. class ArbitrationManagerConfigError(ArbitrationManagerError):
  148. """Raised when manager configuration is invalid."""
  149. pass
  150. class ArbitrationManager:
  151. """
  152. Main arbitration manager for Trixy satellite selection.
  153. This class implements the complete arbitration system as specified in CLAUDE.md,
  154. handling wakeword conflicts between multiple satellites and selecting the optimal
  155. satellite for conversation handling.
  156. The manager integrates with the Trixy event system to automatically handle
  157. wakeword_received events and coordinate with satellite and conversation managers.
  158. """
  159. def __init__(
  160. self,
  161. application,
  162. config: Optional[ArbitrationConfig] = None,
  163. session_manager: Optional[ArbitrationSessionManager] = None
  164. ):
  165. """
  166. Initialize arbitration manager.
  167. Args:
  168. application: Trixy application container
  169. config: Arbitration configuration (created with defaults if None)
  170. session_manager: Session manager (created if None)
  171. """
  172. self.application = application
  173. self.config = config or create_default_config()
  174. # Initialize core components
  175. self.algorithms = ArbitrationAlgorithms(self.config)
  176. self.session_manager = session_manager or create_session_manager(
  177. self.config, self.algorithms
  178. )
  179. # Get application components
  180. self.event_handler = application.get_event_handler()
  181. self.satellite_manager = application.get_satellite_manager()
  182. self.conversation_manager = application.get_conversation_manager()
  183. # State management
  184. self.is_started = False
  185. self.is_running = True
  186. self._lock = threading.RLock()
  187. # Statistics and monitoring
  188. self.stats = ArbitrationStats()
  189. self.history: List[Dict[str, Any]] = []
  190. # Active arbitration tracking
  191. self.active_arbitrations: Dict[str, ArbitrationSession] = {}
  192. self.pending_reports: Dict[str, List[WakewordReport]] = {}
  193. # Threading
  194. self.executor = ThreadPoolExecutor(max_workers=self.config.thread_pool_size)
  195. # Event registry
  196. self._event_handlers_registered = False
  197. pprint("Initialized arbitration manager")
  198. def start(self) -> None:
  199. """
  200. Start the arbitration manager.
  201. This registers event handlers and begins processing wakeword events.
  202. """
  203. with self._lock:
  204. if self.is_started:
  205. pprint("Arbitration manager already started")
  206. return
  207. # Register event handlers
  208. self._register_event_handlers()
  209. self.is_started = True
  210. pprint("Arbitration manager started")
  211. def stop(self) -> None:
  212. """
  213. Stop the arbitration manager.
  214. This unregisters event handlers and cancels active arbitrations.
  215. """
  216. with self._lock:
  217. if not self.is_started:
  218. return
  219. self.is_running = False
  220. # Cancel all active arbitrations
  221. for session in list(self.active_arbitrations.values()):
  222. session.cancel("Manager shutdown")
  223. # Unregister event handlers
  224. self._unregister_event_handlers()
  225. self.is_started = False
  226. pprint("Arbitration manager stopped")
  227. def _register_event_handlers(self) -> None:
  228. """Register event handlers with the event system."""
  229. if self._event_handlers_registered:
  230. return
  231. # Register this object's event handlers
  232. self.event_handler.register_handler_object(self)
  233. self._event_handlers_registered = True
  234. pprint("Registered arbitration event handlers")
  235. def _unregister_event_handlers(self) -> None:
  236. """Unregister event handlers from the event system."""
  237. if not self._event_handlers_registered:
  238. return
  239. # Unregister this object's event handlers
  240. self.event_handler.unregister_handler_object(self)
  241. self._event_handlers_registered = False
  242. pprint("Unregistered arbitration event handlers")
  243. @TrixyEvent(["wakeword_received"])
  244. def handle_wakeword_received(self, event_name: str, event_data: WakewordReceivedEventData) -> None:
  245. """
  246. Handle wakeword_received events from satellites.
  247. This is the main entry point for arbitration when satellites detect wakewords.
  248. Implements the 1-second collection window and satellite selection process.
  249. Args:
  250. event_name: Event name ("wakeword_received")
  251. event_data: Wakeword event data from satellite
  252. """
  253. if not self.is_running:
  254. return
  255. try:
  256. # Convert event data to wakeword report
  257. report = self._convert_event_to_report(event_data)
  258. pprint(f"Received wakeword from {report.satellite_id} "
  259. f"(volume: {report.volume:.3f}, confidence: {report.confidence:.3f})")
  260. # Check if we should start a new arbitration session
  261. session = self._find_or_create_arbitration_session(report)
  262. if session:
  263. # Add report to session
  264. if session.add_wakeword_report(report):
  265. # Trigger report added event
  266. self._trigger_arbitration_report_added_event(session, report)
  267. pprint(f"Added wakeword report to session {session.session_id}")
  268. else:
  269. pprint(f"Failed to add wakeword report to session {session.session_id}")
  270. except Exception as e:
  271. pprint(f"Error handling wakeword event: {e}")
  272. def _convert_event_to_report(self, event_data: WakewordReceivedEventData) -> WakewordReport:
  273. """Convert wakeword event data to wakeword report."""
  274. satellite_info = event_data.satellite_info
  275. speaker_info = event_data.speaker_info or SpeakerInfo(
  276. speaker_id="unknown",
  277. speaker_name="Unknown Speaker"
  278. )
  279. return WakewordReport(
  280. satellite_id=satellite_info.satellite_id,
  281. wakeword_id=event_data.wakeword_id,
  282. volume=event_data.volume,
  283. confidence=event_data.confidence,
  284. timestamp=event_data.timestamp.timestamp(),
  285. room_id=satellite_info.room_id,
  286. satellite_alias=satellite_info.alias,
  287. mac_address=satellite_info.mac_address,
  288. speaker_id=speaker_info.speaker_id,
  289. speaker_name=speaker_info.speaker_name,
  290. speaker_confidence=speaker_info.confidence,
  291. audio_buffer_length=event_data.audio_buffer_length,
  292. )
  293. def _find_or_create_arbitration_session(self, report: WakewordReport) -> Optional[ArbitrationSession]:
  294. """
  295. Find existing arbitration session or create new one for wakeword report.
  296. Args:
  297. report: Wakeword report
  298. Returns:
  299. ArbitrationSession: Session to add report to, or None if rejected
  300. """
  301. with self._lock:
  302. # Check if there's an existing session we can add this report to
  303. current_time = time.time()
  304. for session in list(self.active_arbitrations.values()):
  305. # Check if session is in collection phase
  306. if session.state not in [SessionState.CREATED, SessionState.COLLECTING]:
  307. continue
  308. # Check if report is within time window
  309. session_age = current_time - session.timing.created_at.timestamp()
  310. if session_age > self.config.collection_window_seconds + 0.5: # Small buffer
  311. continue
  312. # Check if same wakeword
  313. if (session.wakeword_reports and
  314. session.wakeword_reports[0].wakeword_id == report.wakeword_id):
  315. return session
  316. # Create new session
  317. session = self._create_new_arbitration_session(report)
  318. return session
  319. def _trigger_arbitration_started_event(self, session: ArbitrationSession, trigger_report: WakewordReport) -> None:
  320. """Trigger arbitration_started event."""
  321. try:
  322. event_data = ArbitrationStartedEventData(
  323. arbitration_session_id=session.session_id,
  324. trigger_satellite_id=trigger_report.satellite_id,
  325. trigger_wakeword_id=trigger_report.wakeword_id,
  326. collection_window_seconds=self.config.collection_window_seconds,
  327. algorithm=self.config.primary_algorithm.value,
  328. expected_participants=1
  329. )
  330. self.event_handler.trigger_event("arbitration_started", event_data)
  331. pprint(f"Triggered arbitration_started event for session {session.session_id}")
  332. except Exception as e:
  333. pprint(f"Error triggering arbitration_started event: {e}")
  334. def _trigger_arbitration_report_added_event(self, session: ArbitrationSession, report: WakewordReport) -> None:
  335. """Trigger arbitration_report_added event."""
  336. try:
  337. event_data = ArbitrationReportAddedEventData(
  338. arbitration_session_id=session.session_id,
  339. satellite_id=report.satellite_id,
  340. wakeword_id=report.wakeword_id,
  341. volume=report.volume,
  342. confidence=report.confidence,
  343. speaker_info=SpeakerInfo(
  344. speaker_id=report.speaker_id,
  345. speaker_name=report.speaker_name,
  346. confidence=report.speaker_confidence
  347. ) if report.speaker_id else None,
  348. satellite_info=SatelliteInfo(
  349. satellite_id=report.satellite_id,
  350. mac_address=report.mac_address,
  351. room_id=report.room_id,
  352. alias=report.satellite_alias,
  353. version="", # Would need to get from satellite manager
  354. audio_ports={} # Would need to get from satellite manager
  355. ) if report.satellite_id else None,
  356. estimated_distance=report.estimated_distance,
  357. room_id=report.room_id,
  358. total_reports_in_session=len(session.wakeword_reports)
  359. )
  360. self.event_handler.trigger_event("arbitration_report_added", event_data)
  361. except Exception as e:
  362. pprint(f"Error triggering arbitration_report_added event: {e}")
  363. def _trigger_arbitration_completed_event(self, session: ArbitrationSession, selection_result: SelectionResult) -> None:
  364. """Trigger arbitration_completed event."""
  365. try:
  366. collection_duration = session.timing.get_collection_duration() or 0.0
  367. arbitration_duration = session.timing.get_arbitration_duration() or 0.0
  368. event_data = ArbitrationCompletedEventData(
  369. arbitration_session_id=session.session_id,
  370. selected_satellite_id=selection_result.selected_satellite_id,
  371. selection_algorithm=selection_result.algorithm_used.value,
  372. selection_score=selection_result.selection_score,
  373. selection_confidence=selection_result.confidence,
  374. total_participants=len(session.wakeword_reports),
  375. collection_duration_ms=collection_duration * 1000,
  376. arbitration_duration_ms=arbitration_duration * 1000,
  377. conversation_id=session.conversation_id,
  378. selection_reason=selection_result.selection_reason,
  379. alternative_selections=selection_result.alternative_selections
  380. )
  381. self.event_handler.trigger_event("arbitration_completed", event_data)
  382. pprint(f"Triggered arbitration_completed event for session {session.session_id}")
  383. except Exception as e:
  384. pprint(f"Error triggering arbitration_completed event: {e}")
  385. def _trigger_satellite_selected_event(self, session: ArbitrationSession, satellite_info: SatelliteInfo, selection_result: SelectionResult, conversation_id: str) -> None:
  386. """Trigger satellite_selected event."""
  387. try:
  388. event_data = SatelliteSelectedEventData(
  389. arbitration_session_id=session.session_id,
  390. selected_satellite_id=selection_result.selected_satellite_id,
  391. satellite_info=satellite_info,
  392. selection_algorithm=selection_result.algorithm_used.value,
  393. selection_score=selection_result.selection_score,
  394. selection_confidence=selection_result.confidence,
  395. conversation_id=conversation_id,
  396. selection_metadata={
  397. 'total_participants': len(session.wakeword_reports),
  398. 'selection_reason': selection_result.selection_reason,
  399. 'tied_candidates': selection_result.tied_candidates
  400. }
  401. )
  402. self.event_handler.trigger_event("satellite_selected", event_data)
  403. except Exception as e:
  404. pprint(f"Error triggering satellite_selected event: {e}")
  405. def _trigger_satellite_ignored_event(self, session: ArbitrationSession, ignored_satellite_id: str, selected_satellite_id: str) -> None:
  406. """Trigger satellite_ignored event."""
  407. try:
  408. # Find the ignored satellite's original report
  409. ignored_report = None
  410. for report in session.wakeword_reports:
  411. if report.satellite_id == ignored_satellite_id:
  412. ignored_report = report
  413. break
  414. satellite_info = None
  415. original_volume = 0.0
  416. original_confidence = 0.0
  417. if ignored_report:
  418. original_volume = ignored_report.volume
  419. original_confidence = ignored_report.confidence
  420. satellite_info = SatelliteInfo(
  421. satellite_id=ignored_report.satellite_id,
  422. mac_address=ignored_report.mac_address,
  423. room_id=ignored_report.room_id,
  424. alias=ignored_report.satellite_alias,
  425. version="",
  426. audio_ports={}
  427. )
  428. event_data = SatelliteIgnoredEventData(
  429. arbitration_session_id=session.session_id,
  430. ignored_satellite_id=ignored_satellite_id,
  431. satellite_info=satellite_info,
  432. selected_satellite_id=selected_satellite_id,
  433. ignore_reason="not_selected",
  434. original_report_volume=original_volume,
  435. original_report_confidence=original_confidence
  436. )
  437. self.event_handler.trigger_event("satellite_ignored", event_data)
  438. except Exception as e:
  439. pprint(f"Error triggering satellite_ignored event: {e}")
  440. def _trigger_arbitration_cancelled_event(self, session: ArbitrationSession, reason: str) -> None:
  441. """Trigger arbitration_cancelled event."""
  442. try:
  443. duration = session.timing.get_total_duration() or 0.0
  444. event_data = ArbitrationCancelledEventData(
  445. arbitration_session_id=session.session_id,
  446. cancellation_reason=reason,
  447. participants_count=len(session.wakeword_reports),
  448. duration_ms=duration * 1000,
  449. cancelled_by="system"
  450. )
  451. self.event_handler.trigger_event("arbitration_cancelled", event_data)
  452. except Exception as e:
  453. pprint(f"Error triggering arbitration_cancelled event: {e}")
  454. def _trigger_arbitration_timed_out_event(self, session: ArbitrationSession) -> None:
  455. """Trigger arbitration_timed_out event."""
  456. try:
  457. duration = session.timing.get_total_duration() or 0.0
  458. event_data = ArbitrationTimedOutEventData(
  459. arbitration_session_id=session.session_id,
  460. timeout_duration_ms=duration * 1000,
  461. participants_count=len(session.wakeword_reports),
  462. reports_received=len(session.wakeword_reports),
  463. timeout_phase=session.phase.value if session.phase else "unknown"
  464. )
  465. self.event_handler.trigger_event("arbitration_timed_out", event_data)
  466. except Exception as e:
  467. pprint(f"Error triggering arbitration_timed_out event: {e}")
  468. def _create_new_arbitration_session(self, trigger_report: WakewordReport) -> ArbitrationSession:
  469. """
  470. Create new arbitration session for wakeword conflict.
  471. Args:
  472. trigger_report: Initial wakeword report that triggered arbitration
  473. Returns:
  474. ArbitrationSession: New arbitration session
  475. """
  476. # Generate session ID
  477. session_id = f"arb_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
  478. # Create session
  479. session = self.session_manager.create_session(
  480. session_id=session_id,
  481. trigger_report=trigger_report,
  482. callback=self._session_event_callback
  483. )
  484. # Track session
  485. self.active_arbitrations[session_id] = session
  486. # Trigger arbitration started event
  487. self._trigger_arbitration_started_event(session, trigger_report)
  488. pprint(f"Created new arbitration session {session_id} for {trigger_report.satellite_id}")
  489. return session
  490. def _session_event_callback(
  491. self,
  492. session: ArbitrationSession,
  493. event_type: str,
  494. data: Any = None
  495. ) -> None:
  496. """
  497. Handle arbitration session events.
  498. Args:
  499. session: Session that generated the event
  500. event_type: Type of event
  501. data: Event data
  502. """
  503. session_id = session.session_id
  504. try:
  505. if event_type == "arbitration_completed":
  506. self._handle_arbitration_completed(session, data)
  507. elif event_type == "session_completed":
  508. self._handle_session_completed(session)
  509. elif event_type == "session_cancelled":
  510. self._handle_session_cancelled(session, data)
  511. elif event_type == "session_timeout":
  512. self._handle_session_timeout(session)
  513. elif event_type == "session_error":
  514. self._handle_session_error(session, data)
  515. except Exception as e:
  516. pprint(f"Error handling session event {event_type} for {session_id}: {e}")
  517. def _handle_arbitration_completed(
  518. self,
  519. session: ArbitrationSession,
  520. selection_result: SelectionResult
  521. ) -> None:
  522. """
  523. Handle completed arbitration by starting conversation with selected satellite.
  524. Args:
  525. session: Completed arbitration session
  526. selection_result: Satellite selection result
  527. """
  528. session_id = session.session_id
  529. selected_satellite_id = selection_result.selected_satellite_id
  530. pprint(f"Arbitration completed for session {session_id}: "
  531. f"selected {selected_satellite_id}")
  532. # Trigger arbitration completed event
  533. self._trigger_arbitration_completed_event(session, selection_result)
  534. try:
  535. # Get selected satellite
  536. selected_satellite = self.satellite_manager.get_satellite(selected_satellite_id)
  537. if not selected_satellite:
  538. pprint(f"Warning: Selected satellite {selected_satellite_id} not found")
  539. session.cancel("Selected satellite not available")
  540. return
  541. # Create satellite info for events
  542. satellite_info = SatelliteInfo(
  543. satellite_id=selected_satellite.satellite_id,
  544. mac_address=selected_satellite.mac_address,
  545. room_id=selected_satellite.room_id,
  546. alias=selected_satellite.alias,
  547. version=selected_satellite.version,
  548. audio_ports=selected_satellite.audio_ports
  549. )
  550. # Start conversation with selected satellite
  551. conversation_id = self._start_conversation_with_satellite(
  552. session, selected_satellite, selection_result
  553. )
  554. if conversation_id:
  555. # Trigger satellite selected event
  556. self._trigger_satellite_selected_event(
  557. session, satellite_info, selection_result, conversation_id
  558. )
  559. # Notify non-selected satellites to ignore
  560. self._notify_non_selected_satellites(session, selected_satellite_id)
  561. # Complete session
  562. session.complete(conversation_id)
  563. pprint(f"Started conversation {conversation_id} with {selected_satellite_id}")
  564. else:
  565. pprint(f"Failed to start conversation with {selected_satellite_id}")
  566. session.cancel("Failed to start conversation")
  567. except Exception as e:
  568. pprint(f"Error handling arbitration completion: {e}")
  569. session.cancel(f"Error: {e}")
  570. def _start_conversation_with_satellite(
  571. self,
  572. session: ArbitrationSession,
  573. selected_satellite,
  574. selection_result: SelectionResult
  575. ) -> Optional[str]:
  576. """
  577. Start conversation with selected satellite.
  578. Args:
  579. session: Arbitration session
  580. selected_satellite: Selected satellite object
  581. selection_result: Selection result
  582. Returns:
  583. str: Conversation ID if successful, None otherwise
  584. """
  585. try:
  586. # Get speaker information from the session
  587. speaker_info = None
  588. selected_report = None
  589. for report in session.wakeword_reports:
  590. if report.satellite_id == selection_result.selected_satellite_id:
  591. selected_report = report
  592. break
  593. if selected_report:
  594. speaker_info = SpeakerInfo(
  595. speaker_id=selected_report.speaker_id,
  596. speaker_name=selected_report.speaker_name,
  597. confidence=selected_report.speaker_confidence
  598. )
  599. # Create satellite info
  600. satellite_info = SatelliteInfo(
  601. satellite_id=selected_satellite.satellite_id,
  602. mac_address=selected_satellite.mac_address,
  603. room_id=selected_satellite.room_id,
  604. alias=selected_satellite.alias,
  605. version=selected_satellite.version,
  606. audio_ports=selected_satellite.audio_ports
  607. )
  608. # Start conversation
  609. conversation_session = self.conversation_manager.start_conversation(
  610. satellite_info=satellite_info,
  611. speaker_info=speaker_info,
  612. trigger_event="wakeword_received",
  613. arbitration_session_id=session.session_id
  614. )
  615. if conversation_session:
  616. return conversation_session.conversation_id
  617. except Exception as e:
  618. pprint(f"Error starting conversation: {e}")
  619. return None
  620. def _notify_non_selected_satellites(
  621. self,
  622. session: ArbitrationSession,
  623. selected_satellite_id: str
  624. ) -> None:
  625. """
  626. Notify non-selected satellites to ignore the wakeword.
  627. Args:
  628. session: Arbitration session
  629. selected_satellite_id: ID of selected satellite
  630. """
  631. for report in session.wakeword_reports:
  632. if report.satellite_id != selected_satellite_id:
  633. try:
  634. satellite = self.satellite_manager.get_satellite(report.satellite_id)
  635. if satellite:
  636. # Send ignore command to satellite
  637. satellite.send_ignore_wakeword_command(session.session_id)
  638. # Trigger satellite ignored event
  639. self._trigger_satellite_ignored_event(session, report.satellite_id, selected_satellite_id)
  640. pprint(f"Notified {report.satellite_id} to ignore wakeword")
  641. except Exception as e:
  642. pprint(f"Error notifying satellite {report.satellite_id}: {e}")
  643. def _handle_session_completed(self, session: ArbitrationSession) -> None:
  644. """Handle session completion."""
  645. self._finalize_session(session)
  646. self.stats.update_from_session(session)
  647. pprint(f"Session {session.session_id} completed successfully")
  648. def _handle_session_cancelled(self, session: ArbitrationSession, reason: str) -> None:
  649. """Handle session cancellation."""
  650. # Trigger arbitration cancelled event
  651. self._trigger_arbitration_cancelled_event(session, reason)
  652. self._finalize_session(session)
  653. self.stats.update_from_session(session)
  654. pprint(f"Session {session.session_id} cancelled: {reason}")
  655. def _handle_session_timeout(self, session: ArbitrationSession) -> None:
  656. """Handle session timeout."""
  657. # Trigger arbitration timed out event
  658. self._trigger_arbitration_timed_out_event(session)
  659. self._finalize_session(session)
  660. self.stats.update_from_session(session)
  661. pprint(f"Session {session.session_id} timed out")
  662. def _handle_session_error(self, session: ArbitrationSession, error: str) -> None:
  663. """Handle session error."""
  664. self._finalize_session(session)
  665. self.stats.update_from_session(session)
  666. pprint(f"Session {session.session_id} error: {error}")
  667. def _finalize_session(self, session: ArbitrationSession) -> None:
  668. """Finalize session and update tracking."""
  669. with self._lock:
  670. session_id = session.session_id
  671. # Remove from active tracking
  672. if session_id in self.active_arbitrations:
  673. del self.active_arbitrations[session_id]
  674. # Add to history
  675. session_summary = session.get_session_summary()
  676. self.history.append(session_summary)
  677. # Keep history within limits
  678. if len(self.history) > self.config.max_history_entries:
  679. self.history.pop(0)
  680. def get_active_arbitrations(self) -> List[ArbitrationSession]:
  681. """
  682. Get list of currently active arbitration sessions.
  683. Returns:
  684. List[ArbitrationSession]: Active arbitration sessions
  685. """
  686. with self._lock:
  687. return list(self.active_arbitrations.values())
  688. def get_arbitration_statistics(self) -> Dict[str, Any]:
  689. """
  690. Get comprehensive arbitration statistics.
  691. Returns:
  692. Dict[str, Any]: Arbitration statistics and metrics
  693. """
  694. with self._lock:
  695. base_stats = self.stats.to_dict()
  696. session_stats = self.session_manager.get_session_statistics()
  697. return {
  698. **base_stats,
  699. 'session_manager': session_stats,
  700. 'active_arbitrations': len(self.active_arbitrations),
  701. 'history_entries': len(self.history),
  702. 'config_algorithm': self.config.primary_algorithm.value,
  703. 'config_fallback_algorithm': self.config.fallback_algorithm.value,
  704. 'config_collection_window': self.config.collection_window_seconds,
  705. }
  706. def get_arbitration_history(
  707. self,
  708. limit: Optional[int] = None,
  709. filter_by: Optional[Dict[str, Any]] = None
  710. ) -> List[Dict[str, Any]]:
  711. """
  712. Get arbitration history with optional filtering.
  713. Args:
  714. limit: Maximum number of entries to return
  715. filter_by: Optional filters to apply
  716. Returns:
  717. List[Dict[str, Any]]: Filtered arbitration history
  718. """
  719. with self._lock:
  720. history = self.history.copy()
  721. # Apply filters
  722. if filter_by:
  723. filtered_history = []
  724. for entry in history:
  725. match = True
  726. for key, value in filter_by.items():
  727. if key not in entry or entry[key] != value:
  728. match = False
  729. break
  730. if match:
  731. filtered_history.append(entry)
  732. history = filtered_history
  733. # Apply limit
  734. if limit and limit > 0:
  735. history = history[-limit:]
  736. return history
  737. def force_arbitration(
  738. self,
  739. reports: List[WakewordReport],
  740. algorithm: Optional[ArbitrationAlgorithm] = None
  741. ) -> Optional[SelectionResult]:
  742. """
  743. Force immediate arbitration with provided reports (for testing/debugging).
  744. Args:
  745. reports: Wakeword reports to arbitrate
  746. algorithm: Algorithm to use (defaults to config)
  747. Returns:
  748. SelectionResult: Selection result if successful
  749. """
  750. if not self.is_started:
  751. raise ArbitrationManagerNotStartedError("Manager not started")
  752. try:
  753. result = self.algorithms.select_satellite(reports, algorithm)
  754. pprint(f"Forced arbitration selected {result.selected_satellite_id} "
  755. f"with score {result.selection_score:.3f}")
  756. return result
  757. except Exception as e:
  758. pprint(f"Forced arbitration failed: {e}")
  759. return None
  760. def update_configuration(self, new_config: ArbitrationConfig) -> None:
  761. """
  762. Update arbitration configuration.
  763. Args:
  764. new_config: New configuration to apply
  765. """
  766. with self._lock:
  767. old_config = self.config
  768. self.config = new_config
  769. # Recreate algorithms with new config
  770. self.algorithms = ArbitrationAlgorithms(new_config)
  771. # Update session manager
  772. self.session_manager.config = new_config
  773. pprint(f"Updated arbitration configuration: "
  774. f"algorithm {old_config.primary_algorithm.value} -> {new_config.primary_algorithm.value}")
  775. def get_configuration(self) -> ArbitrationConfig:
  776. """
  777. Get current arbitration configuration.
  778. Returns:
  779. ArbitrationConfig: Current configuration
  780. """
  781. return self.config
  782. def enable_debug_mode(self, enabled: bool = True) -> None:
  783. """
  784. Enable or disable debug mode.
  785. Args:
  786. enabled: Whether to enable debug mode
  787. """
  788. self.config.enable_debug_logging = enabled
  789. pprint(f"Debug mode {'enabled' if enabled else 'disabled'}")
  790. def benchmark_algorithms(
  791. self,
  792. num_iterations: int = 100,
  793. num_reports: int = 3
  794. ) -> Dict[str, Any]:
  795. """
  796. Benchmark arbitration algorithms.
  797. Args:
  798. num_iterations: Number of benchmark iterations
  799. num_reports: Number of test reports per iteration
  800. Returns:
  801. Dict[str, Any]: Benchmark results
  802. """
  803. test_reports = create_test_reports(num_reports)
  804. results = self.algorithms.benchmark_algorithms(test_reports, num_iterations)
  805. pprint(f"Completed algorithm benchmark: {num_iterations} iterations, {num_reports} reports")
  806. return results
  807. def cleanup(self) -> None:
  808. """Clean up arbitration manager resources."""
  809. self.stop()
  810. if self.executor:
  811. self.executor.shutdown(wait=True)
  812. if self.session_manager:
  813. self.session_manager.cleanup()
  814. if self.algorithms:
  815. self.algorithms.cleanup()
  816. pprint("Arbitration manager cleaned up")
  817. def __del__(self):
  818. """Destructor to ensure cleanup."""
  819. self.cleanup()
  820. def create_arbitration_manager(
  821. application,
  822. config: Optional[ArbitrationConfig] = None
  823. ) -> ArbitrationManager:
  824. """
  825. Create arbitration manager with application and configuration.
  826. Args:
  827. application: Trixy application container
  828. config: Arbitration configuration (defaults created if None)
  829. Returns:
  830. ArbitrationManager: Configured arbitration manager
  831. """
  832. return ArbitrationManager(application, config)
  833. def create_arbitration_manager_with_defaults(application) -> ArbitrationManager:
  834. """
  835. Create arbitration manager with default configuration.
  836. Args:
  837. application: Trixy application container
  838. Returns:
  839. ArbitrationManager: Arbitration manager with defaults
  840. """
  841. config = create_default_config()
  842. return ArbitrationManager(application, config)