arbitration_manager.py 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056
  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. self.logger.info(f"[SERVER] Wakeword command received from satellite {report.satellite_id} "
  259. f"(volume: {report.volume:.3f}, confidence: {report.confidence:.3f})")
  260. pprint(f"Received wakeword from {report.satellite_id} "
  261. f"(volume: {report.volume:.3f}, confidence: {report.confidence:.3f})")
  262. # Check if we should start a new arbitration session
  263. session = self._find_or_create_arbitration_session(report)
  264. if session:
  265. # Add report to session
  266. if session.add_wakeword_report(report):
  267. # Trigger report added event
  268. self._trigger_arbitration_report_added_event(session, report)
  269. pprint(f"Added wakeword report to session {session.session_id}")
  270. else:
  271. pprint(f"Failed to add wakeword report to session {session.session_id}")
  272. except Exception as e:
  273. pprint(f"Error handling wakeword event: {e}")
  274. def _convert_event_to_report(self, event_data: WakewordReceivedEventData) -> WakewordReport:
  275. """Convert wakeword event data to wakeword report."""
  276. satellite_info = event_data.satellite_info
  277. speaker_info = event_data.speaker_info or SpeakerInfo(
  278. speaker_id="unknown",
  279. speaker_name="Unknown Speaker"
  280. )
  281. return WakewordReport(
  282. satellite_id=satellite_info.satellite_id,
  283. wakeword_id=event_data.wakeword_id,
  284. volume=event_data.volume,
  285. confidence=event_data.confidence,
  286. timestamp=event_data.timestamp.timestamp(),
  287. room_id=satellite_info.room_id,
  288. satellite_alias=satellite_info.alias,
  289. mac_address=satellite_info.mac_address,
  290. speaker_id=speaker_info.speaker_id,
  291. speaker_name=speaker_info.speaker_name,
  292. speaker_confidence=speaker_info.confidence,
  293. audio_buffer_length=event_data.audio_buffer_length,
  294. )
  295. def _find_or_create_arbitration_session(self, report: WakewordReport) -> Optional[ArbitrationSession]:
  296. """
  297. Find existing arbitration session or create new one for wakeword report.
  298. Args:
  299. report: Wakeword report
  300. Returns:
  301. ArbitrationSession: Session to add report to, or None if rejected
  302. """
  303. with self._lock:
  304. # Check if there's an existing session we can add this report to
  305. current_time = time.time()
  306. for session in list(self.active_arbitrations.values()):
  307. # Check if session is in collection phase
  308. if session.state not in [SessionState.CREATED, SessionState.COLLECTING]:
  309. continue
  310. # Check if report is within time window
  311. session_age = current_time - session.timing.created_at.timestamp()
  312. if session_age > self.config.collection_window_seconds + 0.5: # Small buffer
  313. continue
  314. # Check if same wakeword
  315. if (session.wakeword_reports and
  316. session.wakeword_reports[0].wakeword_id == report.wakeword_id):
  317. return session
  318. # Create new session
  319. session = self._create_new_arbitration_session(report)
  320. return session
  321. def _trigger_arbitration_started_event(self, session: ArbitrationSession, trigger_report: WakewordReport) -> None:
  322. """Trigger arbitration_started event."""
  323. try:
  324. event_data = ArbitrationStartedEventData(
  325. arbitration_session_id=session.session_id,
  326. trigger_satellite_id=trigger_report.satellite_id,
  327. trigger_wakeword_id=trigger_report.wakeword_id,
  328. collection_window_seconds=self.config.collection_window_seconds,
  329. algorithm=self.config.primary_algorithm.value,
  330. expected_participants=1
  331. )
  332. self.event_handler.trigger_event("arbitration_started", event_data)
  333. pprint(f"Triggered arbitration_started event for session {session.session_id}")
  334. except Exception as e:
  335. pprint(f"Error triggering arbitration_started event: {e}")
  336. def _trigger_arbitration_report_added_event(self, session: ArbitrationSession, report: WakewordReport) -> None:
  337. """Trigger arbitration_report_added event."""
  338. try:
  339. event_data = ArbitrationReportAddedEventData(
  340. arbitration_session_id=session.session_id,
  341. satellite_id=report.satellite_id,
  342. wakeword_id=report.wakeword_id,
  343. volume=report.volume,
  344. confidence=report.confidence,
  345. speaker_info=SpeakerInfo(
  346. speaker_id=report.speaker_id,
  347. speaker_name=report.speaker_name,
  348. confidence=report.speaker_confidence
  349. ) if report.speaker_id else None,
  350. satellite_info=SatelliteInfo(
  351. satellite_id=report.satellite_id,
  352. mac_address=report.mac_address,
  353. room_id=report.room_id,
  354. alias=report.satellite_alias,
  355. version="", # Would need to get from satellite manager
  356. audio_ports={} # Would need to get from satellite manager
  357. ) if report.satellite_id else None,
  358. estimated_distance=report.estimated_distance,
  359. room_id=report.room_id,
  360. total_reports_in_session=len(session.wakeword_reports)
  361. )
  362. self.event_handler.trigger_event("arbitration_report_added", event_data)
  363. except Exception as e:
  364. pprint(f"Error triggering arbitration_report_added event: {e}")
  365. def _trigger_arbitration_completed_event(self, session: ArbitrationSession, selection_result: SelectionResult) -> None:
  366. """Trigger arbitration_completed event."""
  367. try:
  368. collection_duration = session.timing.get_collection_duration() or 0.0
  369. arbitration_duration = session.timing.get_arbitration_duration() or 0.0
  370. event_data = ArbitrationCompletedEventData(
  371. arbitration_session_id=session.session_id,
  372. selected_satellite_id=selection_result.selected_satellite_id,
  373. selection_algorithm=selection_result.algorithm_used.value,
  374. selection_score=selection_result.selection_score,
  375. selection_confidence=selection_result.confidence,
  376. total_participants=len(session.wakeword_reports),
  377. collection_duration_ms=collection_duration * 1000,
  378. arbitration_duration_ms=arbitration_duration * 1000,
  379. conversation_id=session.conversation_id,
  380. selection_reason=selection_result.selection_reason,
  381. alternative_selections=selection_result.alternative_selections
  382. )
  383. self.event_handler.trigger_event("arbitration_completed", event_data)
  384. pprint(f"Triggered arbitration_completed event for session {session.session_id}")
  385. except Exception as e:
  386. pprint(f"Error triggering arbitration_completed event: {e}")
  387. def _trigger_satellite_selected_event(self, session: ArbitrationSession, satellite_info: SatelliteInfo, selection_result: SelectionResult, conversation_id: str) -> None:
  388. """Trigger satellite_selected event."""
  389. try:
  390. event_data = SatelliteSelectedEventData(
  391. arbitration_session_id=session.session_id,
  392. selected_satellite_id=selection_result.selected_satellite_id,
  393. satellite_info=satellite_info,
  394. selection_algorithm=selection_result.algorithm_used.value,
  395. selection_score=selection_result.selection_score,
  396. selection_confidence=selection_result.confidence,
  397. conversation_id=conversation_id,
  398. selection_metadata={
  399. 'total_participants': len(session.wakeword_reports),
  400. 'selection_reason': selection_result.selection_reason,
  401. 'tied_candidates': selection_result.tied_candidates
  402. }
  403. )
  404. self.event_handler.trigger_event("satellite_selected", event_data)
  405. except Exception as e:
  406. pprint(f"Error triggering satellite_selected event: {e}")
  407. def _trigger_satellite_ignored_event(self, session: ArbitrationSession, ignored_satellite_id: str, selected_satellite_id: str) -> None:
  408. """Trigger satellite_ignored event."""
  409. try:
  410. # Find the ignored satellite's original report
  411. ignored_report = None
  412. for report in session.wakeword_reports:
  413. if report.satellite_id == ignored_satellite_id:
  414. ignored_report = report
  415. break
  416. satellite_info = None
  417. original_volume = 0.0
  418. original_confidence = 0.0
  419. if ignored_report:
  420. original_volume = ignored_report.volume
  421. original_confidence = ignored_report.confidence
  422. satellite_info = SatelliteInfo(
  423. satellite_id=ignored_report.satellite_id,
  424. mac_address=ignored_report.mac_address,
  425. room_id=ignored_report.room_id,
  426. alias=ignored_report.satellite_alias,
  427. version="",
  428. audio_ports={}
  429. )
  430. event_data = SatelliteIgnoredEventData(
  431. arbitration_session_id=session.session_id,
  432. ignored_satellite_id=ignored_satellite_id,
  433. satellite_info=satellite_info,
  434. selected_satellite_id=selected_satellite_id,
  435. ignore_reason="not_selected",
  436. original_report_volume=original_volume,
  437. original_report_confidence=original_confidence
  438. )
  439. self.event_handler.trigger_event("satellite_ignored", event_data)
  440. except Exception as e:
  441. pprint(f"Error triggering satellite_ignored event: {e}")
  442. def _trigger_arbitration_cancelled_event(self, session: ArbitrationSession, reason: str) -> None:
  443. """Trigger arbitration_cancelled event."""
  444. try:
  445. duration = session.timing.get_total_duration() or 0.0
  446. event_data = ArbitrationCancelledEventData(
  447. arbitration_session_id=session.session_id,
  448. cancellation_reason=reason,
  449. participants_count=len(session.wakeword_reports),
  450. duration_ms=duration * 1000,
  451. cancelled_by="system"
  452. )
  453. self.event_handler.trigger_event("arbitration_cancelled", event_data)
  454. except Exception as e:
  455. pprint(f"Error triggering arbitration_cancelled event: {e}")
  456. def _trigger_arbitration_timed_out_event(self, session: ArbitrationSession) -> None:
  457. """Trigger arbitration_timed_out event."""
  458. try:
  459. duration = session.timing.get_total_duration() or 0.0
  460. event_data = ArbitrationTimedOutEventData(
  461. arbitration_session_id=session.session_id,
  462. timeout_duration_ms=duration * 1000,
  463. participants_count=len(session.wakeword_reports),
  464. reports_received=len(session.wakeword_reports),
  465. timeout_phase=session.phase.value if session.phase else "unknown"
  466. )
  467. self.event_handler.trigger_event("arbitration_timed_out", event_data)
  468. except Exception as e:
  469. pprint(f"Error triggering arbitration_timed_out event: {e}")
  470. def _create_new_arbitration_session(self, trigger_report: WakewordReport) -> ArbitrationSession:
  471. """
  472. Create new arbitration session for wakeword conflict.
  473. Args:
  474. trigger_report: Initial wakeword report that triggered arbitration
  475. Returns:
  476. ArbitrationSession: New arbitration session
  477. """
  478. # Generate session ID
  479. session_id = f"arb_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
  480. # Create session
  481. session = self.session_manager.create_session(
  482. session_id=session_id,
  483. trigger_report=trigger_report,
  484. callback=self._session_event_callback
  485. )
  486. # Track session
  487. self.active_arbitrations[session_id] = session
  488. # Trigger arbitration started event
  489. self._trigger_arbitration_started_event(session, trigger_report)
  490. pprint(f"Created new arbitration session {session_id} for {trigger_report.satellite_id}")
  491. return session
  492. def _session_event_callback(
  493. self,
  494. session: ArbitrationSession,
  495. event_type: str,
  496. data: Any = None
  497. ) -> None:
  498. """
  499. Handle arbitration session events.
  500. Args:
  501. session: Session that generated the event
  502. event_type: Type of event
  503. data: Event data
  504. """
  505. session_id = session.session_id
  506. try:
  507. if event_type == "arbitration_completed":
  508. self._handle_arbitration_completed(session, data)
  509. elif event_type == "session_completed":
  510. self._handle_session_completed(session)
  511. elif event_type == "session_cancelled":
  512. self._handle_session_cancelled(session, data)
  513. elif event_type == "session_timeout":
  514. self._handle_session_timeout(session)
  515. elif event_type == "session_error":
  516. self._handle_session_error(session, data)
  517. except Exception as e:
  518. pprint(f"Error handling session event {event_type} for {session_id}: {e}")
  519. def _handle_arbitration_completed(
  520. self,
  521. session: ArbitrationSession,
  522. selection_result: SelectionResult
  523. ) -> None:
  524. """
  525. Handle completed arbitration by starting conversation with selected satellite.
  526. Args:
  527. session: Completed arbitration session
  528. selection_result: Satellite selection result
  529. """
  530. session_id = session.session_id
  531. selected_satellite_id = selection_result.selected_satellite_id
  532. self.logger.info(f"[SERVER] Selected satellite {selected_satellite_id} for conversation "
  533. f"(score: {selection_result.selection_score:.3f}, "
  534. f"session: {session_id})")
  535. pprint(f"Arbitration completed for session {session_id}: "
  536. f"selected {selected_satellite_id}")
  537. # Trigger arbitration completed event
  538. self._trigger_arbitration_completed_event(session, selection_result)
  539. try:
  540. # Get selected satellite
  541. selected_satellite = self.satellite_manager.get_satellite(selected_satellite_id)
  542. if not selected_satellite:
  543. pprint(f"Warning: Selected satellite {selected_satellite_id} not found")
  544. session.cancel("Selected satellite not available")
  545. return
  546. # Create satellite info for events
  547. satellite_info = SatelliteInfo(
  548. satellite_id=selected_satellite.satellite_id,
  549. mac_address=selected_satellite.mac_address,
  550. room_id=selected_satellite.room_id,
  551. alias=selected_satellite.alias,
  552. version=selected_satellite.version,
  553. audio_ports=selected_satellite.audio_ports
  554. )
  555. # Start conversation with selected satellite
  556. conversation_id = self._start_conversation_with_satellite(
  557. session, selected_satellite, selection_result
  558. )
  559. if conversation_id:
  560. # Trigger satellite selected event
  561. self._trigger_satellite_selected_event(
  562. session, satellite_info, selection_result, conversation_id
  563. )
  564. # Notify non-selected satellites to ignore
  565. self._notify_non_selected_satellites(session, selected_satellite_id)
  566. # Complete session
  567. session.complete(conversation_id)
  568. pprint(f"Started conversation {conversation_id} with {selected_satellite_id}")
  569. else:
  570. pprint(f"Failed to start conversation with {selected_satellite_id}")
  571. session.cancel("Failed to start conversation")
  572. except Exception as e:
  573. pprint(f"Error handling arbitration completion: {e}")
  574. session.cancel(f"Error: {e}")
  575. def _start_conversation_with_satellite(
  576. self,
  577. session: ArbitrationSession,
  578. selected_satellite,
  579. selection_result: SelectionResult
  580. ) -> Optional[str]:
  581. """
  582. Start conversation with selected satellite.
  583. Args:
  584. session: Arbitration session
  585. selected_satellite: Selected satellite object
  586. selection_result: Selection result
  587. Returns:
  588. str: Conversation ID if successful, None otherwise
  589. """
  590. try:
  591. # Get speaker information from the session
  592. speaker_info = None
  593. selected_report = None
  594. for report in session.wakeword_reports:
  595. if report.satellite_id == selection_result.selected_satellite_id:
  596. selected_report = report
  597. break
  598. if selected_report:
  599. speaker_info = SpeakerInfo(
  600. speaker_id=selected_report.speaker_id,
  601. speaker_name=selected_report.speaker_name,
  602. confidence=selected_report.speaker_confidence
  603. )
  604. # Create satellite info
  605. satellite_info = SatelliteInfo(
  606. satellite_id=selected_satellite.satellite_id,
  607. mac_address=selected_satellite.mac_address,
  608. room_id=selected_satellite.room_id,
  609. alias=selected_satellite.alias,
  610. version=selected_satellite.version,
  611. audio_ports=selected_satellite.audio_ports
  612. )
  613. # Start conversation
  614. conversation_session = self.conversation_manager.start_conversation(
  615. satellite_info=satellite_info,
  616. speaker_info=speaker_info,
  617. trigger_event="wakeword_received",
  618. arbitration_session_id=session.session_id
  619. )
  620. if conversation_session:
  621. return conversation_session.conversation_id
  622. except Exception as e:
  623. pprint(f"Error starting conversation: {e}")
  624. return None
  625. def _notify_non_selected_satellites(
  626. self,
  627. session: ArbitrationSession,
  628. selected_satellite_id: str
  629. ) -> None:
  630. """
  631. Notify non-selected satellites to ignore the wakeword.
  632. Args:
  633. session: Arbitration session
  634. selected_satellite_id: ID of selected satellite
  635. """
  636. for report in session.wakeword_reports:
  637. if report.satellite_id != selected_satellite_id:
  638. try:
  639. satellite = self.satellite_manager.get_satellite(report.satellite_id)
  640. if satellite:
  641. # Send ignore command to satellite
  642. satellite.send_ignore_wakeword_command(session.session_id)
  643. # Trigger satellite ignored event
  644. self._trigger_satellite_ignored_event(session, report.satellite_id, selected_satellite_id)
  645. pprint(f"Notified {report.satellite_id} to ignore wakeword")
  646. except Exception as e:
  647. pprint(f"Error notifying satellite {report.satellite_id}: {e}")
  648. def _handle_session_completed(self, session: ArbitrationSession) -> None:
  649. """Handle session completion."""
  650. self._finalize_session(session)
  651. self.stats.update_from_session(session)
  652. pprint(f"Session {session.session_id} completed successfully")
  653. def _handle_session_cancelled(self, session: ArbitrationSession, reason: str) -> None:
  654. """Handle session cancellation."""
  655. # Trigger arbitration cancelled event
  656. self._trigger_arbitration_cancelled_event(session, reason)
  657. self._finalize_session(session)
  658. self.stats.update_from_session(session)
  659. pprint(f"Session {session.session_id} cancelled: {reason}")
  660. def _handle_session_timeout(self, session: ArbitrationSession) -> None:
  661. """Handle session timeout."""
  662. # Trigger arbitration timed out event
  663. self._trigger_arbitration_timed_out_event(session)
  664. self._finalize_session(session)
  665. self.stats.update_from_session(session)
  666. pprint(f"Session {session.session_id} timed out")
  667. def _handle_session_error(self, session: ArbitrationSession, error: str) -> None:
  668. """Handle session error."""
  669. self._finalize_session(session)
  670. self.stats.update_from_session(session)
  671. pprint(f"Session {session.session_id} error: {error}")
  672. def _finalize_session(self, session: ArbitrationSession) -> None:
  673. """Finalize session and update tracking."""
  674. with self._lock:
  675. session_id = session.session_id
  676. # Remove from active tracking
  677. if session_id in self.active_arbitrations:
  678. del self.active_arbitrations[session_id]
  679. # Add to history
  680. session_summary = session.get_session_summary()
  681. self.history.append(session_summary)
  682. # Keep history within limits
  683. if len(self.history) > self.config.max_history_entries:
  684. self.history.pop(0)
  685. def get_active_arbitrations(self) -> List[ArbitrationSession]:
  686. """
  687. Get list of currently active arbitration sessions.
  688. Returns:
  689. List[ArbitrationSession]: Active arbitration sessions
  690. """
  691. with self._lock:
  692. return list(self.active_arbitrations.values())
  693. def get_arbitration_statistics(self) -> Dict[str, Any]:
  694. """
  695. Get comprehensive arbitration statistics.
  696. Returns:
  697. Dict[str, Any]: Arbitration statistics and metrics
  698. """
  699. with self._lock:
  700. base_stats = self.stats.to_dict()
  701. session_stats = self.session_manager.get_session_statistics()
  702. return {
  703. **base_stats,
  704. 'session_manager': session_stats,
  705. 'active_arbitrations': len(self.active_arbitrations),
  706. 'history_entries': len(self.history),
  707. 'config_algorithm': self.config.primary_algorithm.value,
  708. 'config_fallback_algorithm': self.config.fallback_algorithm.value,
  709. 'config_collection_window': self.config.collection_window_seconds,
  710. }
  711. def get_arbitration_history(
  712. self,
  713. limit: Optional[int] = None,
  714. filter_by: Optional[Dict[str, Any]] = None
  715. ) -> List[Dict[str, Any]]:
  716. """
  717. Get arbitration history with optional filtering.
  718. Args:
  719. limit: Maximum number of entries to return
  720. filter_by: Optional filters to apply
  721. Returns:
  722. List[Dict[str, Any]]: Filtered arbitration history
  723. """
  724. with self._lock:
  725. history = self.history.copy()
  726. # Apply filters
  727. if filter_by:
  728. filtered_history = []
  729. for entry in history:
  730. match = True
  731. for key, value in filter_by.items():
  732. if key not in entry or entry[key] != value:
  733. match = False
  734. break
  735. if match:
  736. filtered_history.append(entry)
  737. history = filtered_history
  738. # Apply limit
  739. if limit and limit > 0:
  740. history = history[-limit:]
  741. return history
  742. def force_arbitration(
  743. self,
  744. reports: List[WakewordReport],
  745. algorithm: Optional[ArbitrationAlgorithm] = None
  746. ) -> Optional[SelectionResult]:
  747. """
  748. Force immediate arbitration with provided reports (for testing/debugging).
  749. Args:
  750. reports: Wakeword reports to arbitrate
  751. algorithm: Algorithm to use (defaults to config)
  752. Returns:
  753. SelectionResult: Selection result if successful
  754. """
  755. if not self.is_started:
  756. raise ArbitrationManagerNotStartedError("Manager not started")
  757. try:
  758. result = self.algorithms.select_satellite(reports, algorithm)
  759. pprint(f"Forced arbitration selected {result.selected_satellite_id} "
  760. f"with score {result.selection_score:.3f}")
  761. return result
  762. except Exception as e:
  763. pprint(f"Forced arbitration failed: {e}")
  764. return None
  765. def update_configuration(self, new_config: ArbitrationConfig) -> None:
  766. """
  767. Update arbitration configuration.
  768. Args:
  769. new_config: New configuration to apply
  770. """
  771. with self._lock:
  772. old_config = self.config
  773. self.config = new_config
  774. # Recreate algorithms with new config
  775. self.algorithms = ArbitrationAlgorithms(new_config)
  776. # Update session manager
  777. self.session_manager.config = new_config
  778. pprint(f"Updated arbitration configuration: "
  779. f"algorithm {old_config.primary_algorithm.value} -> {new_config.primary_algorithm.value}")
  780. def get_configuration(self) -> ArbitrationConfig:
  781. """
  782. Get current arbitration configuration.
  783. Returns:
  784. ArbitrationConfig: Current configuration
  785. """
  786. return self.config
  787. def enable_debug_mode(self, enabled: bool = True) -> None:
  788. """
  789. Enable or disable debug mode.
  790. Args:
  791. enabled: Whether to enable debug mode
  792. """
  793. self.config.enable_debug_logging = enabled
  794. pprint(f"Debug mode {'enabled' if enabled else 'disabled'}")
  795. def benchmark_algorithms(
  796. self,
  797. num_iterations: int = 100,
  798. num_reports: int = 3
  799. ) -> Dict[str, Any]:
  800. """
  801. Benchmark arbitration algorithms.
  802. Args:
  803. num_iterations: Number of benchmark iterations
  804. num_reports: Number of test reports per iteration
  805. Returns:
  806. Dict[str, Any]: Benchmark results
  807. """
  808. test_reports = create_test_reports(num_reports)
  809. results = self.algorithms.benchmark_algorithms(test_reports, num_iterations)
  810. pprint(f"Completed algorithm benchmark: {num_iterations} iterations, {num_reports} reports")
  811. return results
  812. def cleanup(self) -> None:
  813. """Clean up arbitration manager resources."""
  814. self.stop()
  815. if self.executor:
  816. self.executor.shutdown(wait=True)
  817. if self.session_manager:
  818. self.session_manager.cleanup()
  819. if self.algorithms:
  820. self.algorithms.cleanup()
  821. pprint("Arbitration manager cleaned up")
  822. def __del__(self):
  823. """Destructor to ensure cleanup."""
  824. self.cleanup()
  825. def create_arbitration_manager(
  826. application,
  827. config: Optional[ArbitrationConfig] = None
  828. ) -> ArbitrationManager:
  829. """
  830. Create arbitration manager with application and configuration.
  831. Args:
  832. application: Trixy application container
  833. config: Arbitration configuration (defaults created if None)
  834. Returns:
  835. ArbitrationManager: Configured arbitration manager
  836. """
  837. return ArbitrationManager(application, config)
  838. def create_arbitration_manager_with_defaults(application) -> ArbitrationManager:
  839. """
  840. Create arbitration manager with default configuration.
  841. Args:
  842. application: Trixy application container
  843. Returns:
  844. ArbitrationManager: Arbitration manager with defaults
  845. """
  846. config = create_default_config()
  847. return ArbitrationManager(application, config)