satellite.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  1. """
  2. Satellite Class Implementation
  3. This module implements the Satellite class that represents individual satellites (clients)
  4. in the Trixy system. Each satellite instance provides methods for communication, status
  5. monitoring, and connection management.
  6. The Satellite class implements the interface specified in CLAUDE.md:
  7. - Properties: room_id, mac_address, alias_name, IP, sockets, last_conversation
  8. - Methods: say(), disconnect(), reconnect(), get_status()
  9. - Integration with network system for socket management
  10. - Integration with event system for satellite events
  11. - Thread-safe operations
  12. """
  13. import time
  14. import threading
  15. import socket
  16. from typing import Optional, Dict, Any, List, Union, Tuple
  17. from dataclasses import dataclass, field
  18. from datetime import datetime, timezone
  19. from enum import Enum
  20. import json
  21. import uuid
  22. import weakref
  23. # Import network commands for satellite communication
  24. try:
  25. from ..network.cmd.satellite import (
  26. SatelliteDisconnectCommand,
  27. SatelliteStatusCommand,
  28. create_satellite_disconnect
  29. )
  30. from ..network.cmd.audio import (
  31. TTSPlaybackCommand,
  32. AudioStreamCommand
  33. )
  34. except ImportError:
  35. # Fallback for development
  36. SatelliteDisconnectCommand = None
  37. SatelliteStatusCommand = None
  38. TTSPlaybackCommand = None
  39. AudioStreamCommand = None
  40. def pprint(message: str) -> None:
  41. """Satellite logging function."""
  42. print(f"[SATELLITE] {message}")
  43. class SatelliteStatus(Enum):
  44. """Status of a satellite connection."""
  45. REGISTERED = "registered"
  46. NOT_CONNECTED = "not_connected"
  47. CONNECTING = "connecting"
  48. CONNECTED = "connected"
  49. DISCONNECTING = "disconnecting"
  50. ERROR = "error"
  51. BLACKLISTED = "blacklisted"
  52. class SatelliteCapability(Enum):
  53. """Capabilities that a satellite can support."""
  54. WAKEWORD_DETECTION = "wakeword_detection"
  55. VOICE_RECOGNITION = "voice_recognition"
  56. AUDIO_INPUT = "audio_input"
  57. AUDIO_OUTPUT = "audio_output"
  58. MUSIC_PLAYBACK = "music_playback"
  59. TEXT_TO_SPEECH = "text_to_speech"
  60. LED_CONTROL = "led_control"
  61. BUTTON_INPUT = "button_input"
  62. SENSOR_DATA = "sensor_data"
  63. @dataclass
  64. class AudioPortInfo:
  65. """Information about audio ports for a satellite."""
  66. raw_input_port: Optional[int] = None
  67. raw_output_port: Optional[int] = None
  68. music_output_port: Optional[int] = None
  69. control_port: Optional[int] = None
  70. def to_dict(self) -> Dict[str, Any]:
  71. """Convert to dictionary representation."""
  72. return {
  73. "raw_input_port": self.raw_input_port,
  74. "raw_output_port": self.raw_output_port,
  75. "music_output_port": self.music_output_port,
  76. "control_port": self.control_port,
  77. }
  78. @classmethod
  79. def from_dict(cls, data: Dict[str, Any]) -> 'AudioPortInfo':
  80. """Create from dictionary representation."""
  81. return cls(
  82. raw_input_port=data.get("raw_input_port"),
  83. raw_output_port=data.get("raw_output_port"),
  84. music_output_port=data.get("music_output_port"),
  85. control_port=data.get("control_port"),
  86. )
  87. @dataclass
  88. class SatelliteInfo:
  89. """Complete information about a satellite."""
  90. satellite_id: str
  91. mac_address: str
  92. room_id: str
  93. alias_name: str
  94. ip_address: Optional[str] = None
  95. version: str = "1.0.0"
  96. capabilities: List[SatelliteCapability] = field(default_factory=list)
  97. device_info: Dict[str, Any] = field(default_factory=dict)
  98. registered_at: Optional[datetime] = None
  99. last_seen: Optional[datetime] = None
  100. def to_dict(self) -> Dict[str, Any]:
  101. """Convert to dictionary representation."""
  102. return {
  103. "satellite_id": self.satellite_id,
  104. "mac_address": self.mac_address,
  105. "room_id": self.room_id,
  106. "alias_name": self.alias_name,
  107. "ip_address": self.ip_address,
  108. "version": self.version,
  109. "capabilities": [cap.value for cap in self.capabilities],
  110. "device_info": self.device_info,
  111. "registered_at": self.registered_at.isoformat() if self.registered_at else None,
  112. "last_seen": self.last_seen.isoformat() if self.last_seen else None,
  113. }
  114. @classmethod
  115. def from_dict(cls, data: Dict[str, Any]) -> 'SatelliteInfo':
  116. """Create from dictionary representation."""
  117. return cls(
  118. satellite_id=data["satellite_id"],
  119. mac_address=data["mac_address"],
  120. room_id=data["room_id"],
  121. alias_name=data["alias_name"],
  122. ip_address=data.get("ip_address"),
  123. version=data.get("version", "1.0.0"),
  124. capabilities=[SatelliteCapability(cap) for cap in data.get("capabilities", [])],
  125. device_info=data.get("device_info", {}),
  126. registered_at=datetime.fromisoformat(data["registered_at"]) if data.get("registered_at") else None,
  127. last_seen=datetime.fromisoformat(data["last_seen"]) if data.get("last_seen") else None,
  128. )
  129. class SatelliteError(Exception):
  130. """Base exception for satellite-related errors."""
  131. pass
  132. class SatelliteConnectionError(SatelliteError):
  133. """Raised when satellite connection operations fail."""
  134. pass
  135. class SatelliteCommunicationError(SatelliteError):
  136. """Raised when satellite communication fails."""
  137. pass
  138. class Satellite:
  139. """
  140. Represents an individual satellite (client) in the Trixy system.
  141. This class provides the interface for interacting with a single satellite,
  142. including communication, status monitoring, and connection management.
  143. The satellite maintains its own connection state and provides thread-safe
  144. operations for multi-threaded environments.
  145. """
  146. def __init__(
  147. self,
  148. satellite_info: SatelliteInfo,
  149. application,
  150. network_manager=None
  151. ):
  152. """
  153. Initialize a satellite instance.
  154. Args:
  155. satellite_info: Complete satellite information
  156. application: Application container reference
  157. network_manager: Network manager for communication
  158. """
  159. self._info = satellite_info
  160. self._application = application
  161. self._application_ref = weakref.ref(application) if application else None
  162. self._network_manager = network_manager
  163. # Connection state
  164. self._status = SatelliteStatus.REGISTERED
  165. self._connected_at: Optional[datetime] = None
  166. self._last_heartbeat: Optional[datetime] = None
  167. self._reconnect_attempts = 0
  168. self._max_reconnect_attempts = 3
  169. # Socket information
  170. self._sockets: Dict[str, Any] = {}
  171. self._audio_ports = AudioPortInfo()
  172. # Conversation tracking
  173. self._last_conversation: Optional[Dict[str, Any]] = None
  174. self._conversation_history: List[Dict[str, Any]] = []
  175. # Thread safety
  176. self._lock = threading.RLock()
  177. # Statistics
  178. self._stats = {
  179. "connection_count": 0,
  180. "message_count": 0,
  181. "error_count": 0,
  182. "last_error": None,
  183. "uptime": 0.0,
  184. }
  185. pprint(f"Satellite created: {self.alias_name} ({self.mac_address})")
  186. # Properties as specified in CLAUDE.md
  187. @property
  188. def room_id(self) -> str:
  189. """Get the room ID where this satellite is located."""
  190. return self._info.room_id
  191. @room_id.setter
  192. def room_id(self, value: str) -> None:
  193. """Set the room ID (requires re-registration)."""
  194. with self._lock:
  195. old_room = self._info.room_id
  196. self._info.room_id = value
  197. pprint(f"Satellite {self.alias_name} moved from {old_room} to {value}")
  198. @property
  199. def mac_address(self) -> str:
  200. """Get the MAC address of this satellite."""
  201. return self._info.mac_address
  202. @property
  203. def alias_name(self) -> str:
  204. """Get the alias name of this satellite."""
  205. return self._info.alias_name
  206. @alias_name.setter
  207. def alias_name(self, value: str) -> None:
  208. """Set the alias name."""
  209. with self._lock:
  210. old_alias = self._info.alias_name
  211. self._info.alias_name = value
  212. pprint(f"Satellite {old_alias} renamed to {value}")
  213. @property
  214. def IP(self) -> Optional[str]:
  215. """Get the IP address of this satellite."""
  216. return self._info.ip_address
  217. @property
  218. def ip_address(self) -> Optional[str]:
  219. """Get the IP address of this satellite (alternative property name)."""
  220. return self._info.ip_address
  221. @property
  222. def sockets(self) -> Dict[str, Any]:
  223. """Get the socket connections for this satellite."""
  224. with self._lock:
  225. return self._sockets.copy()
  226. @property
  227. def last_conversation(self) -> Optional[Dict[str, Any]]:
  228. """Get the last conversation data."""
  229. return self._last_conversation
  230. @property
  231. def satellite_id(self) -> str:
  232. """Get the unique satellite ID."""
  233. return self._info.satellite_id
  234. @property
  235. def version(self) -> str:
  236. """Get the satellite software version."""
  237. return self._info.version
  238. @property
  239. def capabilities(self) -> List[SatelliteCapability]:
  240. """Get the satellite capabilities."""
  241. return self._info.capabilities.copy()
  242. @property
  243. def status(self) -> SatelliteStatus:
  244. """Get the current satellite status."""
  245. return self._status
  246. @property
  247. def is_connected(self) -> bool:
  248. """Check if the satellite is currently connected."""
  249. return self._status == SatelliteStatus.CONNECTED
  250. @property
  251. def is_registered(self) -> bool:
  252. """Check if the satellite is registered."""
  253. return self._status != SatelliteStatus.NOT_CONNECTED
  254. @property
  255. def uptime(self) -> float:
  256. """Get the uptime in seconds since connection."""
  257. if not self._connected_at:
  258. return 0.0
  259. return (datetime.now(timezone.utc) - self._connected_at).total_seconds()
  260. @property
  261. def audio_ports(self) -> AudioPortInfo:
  262. """Get the audio port information."""
  263. return self._audio_ports
  264. # Methods as specified in CLAUDE.md
  265. def say(self, text: str, voice_settings: Optional[Dict[str, Any]] = None) -> bool:
  266. """
  267. Send text-to-speech audio to this satellite.
  268. Args:
  269. text: Text to speak
  270. voice_settings: Optional voice configuration
  271. Returns:
  272. bool: True if message was sent successfully
  273. """
  274. if not self.is_connected:
  275. pprint(f"Cannot send TTS to {self.alias_name}: not connected")
  276. return False
  277. try:
  278. with self._lock:
  279. # Create TTS command if available
  280. if TTSPlaybackCommand is not None:
  281. tts_command = TTSPlaybackCommand(
  282. text=text,
  283. satellite_id=self.satellite_id,
  284. voice_settings=voice_settings or {}
  285. )
  286. # Send via network manager
  287. if self._network_manager:
  288. success = self._network_manager.send_command(
  289. self.ip_address,
  290. tts_command
  291. )
  292. if success:
  293. self._stats["message_count"] += 1
  294. pprint(f"TTS sent to {self.alias_name}: '{text[:50]}...'")
  295. return True
  296. # Fallback: trigger event for TTS processing
  297. if self._application_ref and self._application_ref():
  298. app = self._application_ref()
  299. event_handler = app.get_event_handler()
  300. # Import event data factory
  301. try:
  302. from ..events import EventDataFactory
  303. tts_data = EventDataFactory.create_event_data(
  304. "tts_received",
  305. conversation_id=str(uuid.uuid4()),
  306. text=text,
  307. satellite_id=self.satellite_id,
  308. voice_settings=voice_settings or {},
  309. audio_data=b"", # Will be filled by TTS plugin
  310. )
  311. event_handler.trigger_event("tts_received", tts_data)
  312. self._stats["message_count"] += 1
  313. return True
  314. except ImportError:
  315. pass
  316. pprint(f"Failed to send TTS to {self.alias_name}: no communication method available")
  317. return False
  318. except Exception as e:
  319. self._stats["error_count"] += 1
  320. self._stats["last_error"] = str(e)
  321. pprint(f"Error sending TTS to {self.alias_name}: {e}")
  322. return False
  323. def disconnect(self, reason: str = "normal_disconnect", graceful: bool = True) -> bool:
  324. """
  325. Disconnect this satellite from the server.
  326. Args:
  327. reason: Reason for disconnection
  328. graceful: Whether to perform graceful disconnect
  329. Returns:
  330. bool: True if disconnect was initiated successfully
  331. """
  332. if not self.is_connected:
  333. pprint(f"Satellite {self.alias_name} is not connected")
  334. return True
  335. try:
  336. with self._lock:
  337. pprint(f"Disconnecting satellite {self.alias_name} (reason: {reason})")
  338. self._status = SatelliteStatus.DISCONNECTING
  339. # Send disconnect command if available
  340. if SatelliteDisconnectCommand is not None and self._network_manager:
  341. disconnect_cmd = create_satellite_disconnect(
  342. self.satellite_id,
  343. reason=reason,
  344. graceful=graceful
  345. )
  346. self._network_manager.send_command(
  347. self.ip_address,
  348. disconnect_cmd
  349. )
  350. # Close sockets
  351. for socket_name, socket_obj in self._sockets.items():
  352. try:
  353. if hasattr(socket_obj, 'close'):
  354. socket_obj.close()
  355. pprint(f"Closed {socket_name} socket for {self.alias_name}")
  356. except Exception as e:
  357. pprint(f"Error closing {socket_name} socket: {e}")
  358. self._sockets.clear()
  359. self._status = SatelliteStatus.NOT_CONNECTED
  360. self._connected_at = None
  361. self._last_heartbeat = None
  362. # Trigger disconnect event
  363. self._trigger_disconnect_event(reason, graceful)
  364. pprint(f"Satellite {self.alias_name} disconnected successfully")
  365. return True
  366. except Exception as e:
  367. self._status = SatelliteStatus.ERROR
  368. self._stats["error_count"] += 1
  369. self._stats["last_error"] = str(e)
  370. pprint(f"Error disconnecting satellite {self.alias_name}: {e}")
  371. return False
  372. def reconnect(self) -> bool:
  373. """
  374. Attempt to reconnect this satellite.
  375. Returns:
  376. bool: True if reconnection was initiated successfully
  377. """
  378. if self.is_connected:
  379. pprint(f"Satellite {self.alias_name} is already connected")
  380. return True
  381. if self._reconnect_attempts >= self._max_reconnect_attempts:
  382. pprint(f"Max reconnection attempts reached for {self.alias_name}")
  383. return False
  384. try:
  385. with self._lock:
  386. pprint(f"Reconnecting satellite {self.alias_name} (attempt {self._reconnect_attempts + 1})")
  387. self._status = SatelliteStatus.CONNECTING
  388. self._reconnect_attempts += 1
  389. # In a real implementation, this would establish new socket connections
  390. # For now, we simulate reconnection by updating status
  391. # The actual reconnection logic would be handled by the network manager
  392. if self._network_manager:
  393. # Attempt to re-establish connection
  394. success = self._network_manager.reconnect_satellite(self)
  395. if success:
  396. self._status = SatelliteStatus.CONNECTED
  397. self._connected_at = datetime.now(timezone.utc)
  398. self._reconnect_attempts = 0
  399. self._stats["connection_count"] += 1
  400. # Trigger connect event
  401. self._trigger_connect_event()
  402. pprint(f"Satellite {self.alias_name} reconnected successfully")
  403. return True
  404. self._status = SatelliteStatus.ERROR
  405. pprint(f"Failed to reconnect satellite {self.alias_name}")
  406. return False
  407. except Exception as e:
  408. self._status = SatelliteStatus.ERROR
  409. self._stats["error_count"] += 1
  410. self._stats["last_error"] = str(e)
  411. pprint(f"Error reconnecting satellite {self.alias_name}: {e}")
  412. return False
  413. def get_status(self) -> Dict[str, Any]:
  414. """
  415. Get comprehensive status information for this satellite.
  416. Returns:
  417. Dict containing all status information
  418. """
  419. with self._lock:
  420. return {
  421. "satellite_info": self._info.to_dict(),
  422. "connection": {
  423. "status": self._status.value,
  424. "connected_at": self._connected_at.isoformat() if self._connected_at else None,
  425. "last_heartbeat": self._last_heartbeat.isoformat() if self._last_heartbeat else None,
  426. "uptime_seconds": self.uptime,
  427. "reconnect_attempts": self._reconnect_attempts,
  428. "ip_address": self.ip_address,
  429. },
  430. "sockets": {
  431. name: {
  432. "connected": bool(sock),
  433. "type": type(sock).__name__ if sock else None
  434. } for name, sock in self._sockets.items()
  435. },
  436. "audio_ports": self._audio_ports.to_dict(),
  437. "conversation": {
  438. "last_conversation": self._last_conversation,
  439. "conversation_count": len(self._conversation_history),
  440. },
  441. "statistics": self._stats.copy(),
  442. "capabilities": [cap.value for cap in self.capabilities],
  443. "timestamps": {
  444. "registered_at": self._info.registered_at.isoformat() if self._info.registered_at else None,
  445. "last_seen": self._info.last_seen.isoformat() if self._info.last_seen else None,
  446. "status_checked_at": datetime.now(timezone.utc).isoformat(),
  447. }
  448. }
  449. # Additional utility methods
  450. def update_last_seen(self) -> None:
  451. """Update the last seen timestamp."""
  452. with self._lock:
  453. self._info.last_seen = datetime.now(timezone.utc)
  454. self._last_heartbeat = self._info.last_seen
  455. def update_ip_address(self, ip_address: str) -> None:
  456. """Update the IP address."""
  457. with self._lock:
  458. old_ip = self._info.ip_address
  459. self._info.ip_address = ip_address
  460. if old_ip != ip_address:
  461. pprint(f"IP address updated for {self.alias_name}: {old_ip} -> {ip_address}")
  462. def add_socket(self, socket_name: str, socket_obj: Any) -> None:
  463. """Add a socket connection."""
  464. with self._lock:
  465. self._sockets[socket_name] = socket_obj
  466. pprint(f"Socket '{socket_name}' added for {self.alias_name}")
  467. def remove_socket(self, socket_name: str) -> None:
  468. """Remove a socket connection."""
  469. with self._lock:
  470. if socket_name in self._sockets:
  471. del self._sockets[socket_name]
  472. pprint(f"Socket '{socket_name}' removed for {self.alias_name}")
  473. def update_audio_ports(self, audio_ports: AudioPortInfo) -> None:
  474. """Update the audio port information."""
  475. with self._lock:
  476. self._audio_ports = audio_ports
  477. pprint(f"Audio ports updated for {self.alias_name}")
  478. def record_conversation(self, conversation_data: Dict[str, Any]) -> None:
  479. """Record a conversation for this satellite."""
  480. with self._lock:
  481. self._last_conversation = conversation_data
  482. self._conversation_history.append(conversation_data)
  483. # Keep only recent conversations
  484. if len(self._conversation_history) > 100:
  485. self._conversation_history = self._conversation_history[-100:]
  486. def set_connected(self, ip_address: str, audio_ports: AudioPortInfo) -> None:
  487. """Mark satellite as connected."""
  488. with self._lock:
  489. self._status = SatelliteStatus.CONNECTED
  490. self._info.ip_address = ip_address
  491. self._audio_ports = audio_ports
  492. self._connected_at = datetime.now(timezone.utc)
  493. self._reconnect_attempts = 0
  494. self._stats["connection_count"] += 1
  495. self._trigger_connect_event()
  496. def set_disconnected(self, reason: str = "unknown") -> None:
  497. """Mark satellite as disconnected."""
  498. with self._lock:
  499. was_connected = self._status == SatelliteStatus.CONNECTED
  500. self._status = SatelliteStatus.NOT_CONNECTED
  501. self._connected_at = None
  502. self._sockets.clear()
  503. if was_connected:
  504. self._trigger_disconnect_event(reason, False)
  505. def set_error(self, error_message: str) -> None:
  506. """Mark satellite as in error state."""
  507. with self._lock:
  508. self._status = SatelliteStatus.ERROR
  509. self._stats["error_count"] += 1
  510. self._stats["last_error"] = error_message
  511. pprint(f"Satellite {self.alias_name} entered error state: {error_message}")
  512. def has_capability(self, capability: SatelliteCapability) -> bool:
  513. """Check if satellite has a specific capability."""
  514. return capability in self._info.capabilities
  515. def add_capability(self, capability: SatelliteCapability) -> None:
  516. """Add a capability to this satellite."""
  517. with self._lock:
  518. if capability not in self._info.capabilities:
  519. self._info.capabilities.append(capability)
  520. pprint(f"Capability {capability.value} added to {self.alias_name}")
  521. def remove_capability(self, capability: SatelliteCapability) -> None:
  522. """Remove a capability from this satellite."""
  523. with self._lock:
  524. if capability in self._info.capabilities:
  525. self._info.capabilities.remove(capability)
  526. pprint(f"Capability {capability.value} removed from {self.alias_name}")
  527. def _trigger_connect_event(self) -> None:
  528. """Trigger satellite connected event."""
  529. try:
  530. if self._application_ref and self._application_ref():
  531. app = self._application_ref()
  532. event_handler = app.get_event_handler()
  533. from ..events import EventDataFactory
  534. connect_data = EventDataFactory.create_event_data(
  535. "satellite_connected",
  536. satellite_id=self.satellite_id,
  537. mac_address=self.mac_address,
  538. room_id=self.room_id,
  539. alias_name=self.alias_name,
  540. ip_address=self.ip_address,
  541. connection_time=datetime.now(timezone.utc).isoformat()
  542. )
  543. event_handler.trigger_event("satellite_connected", connect_data)
  544. except Exception as e:
  545. pprint(f"Error triggering connect event for {self.alias_name}: {e}")
  546. def _trigger_disconnect_event(self, reason: str, graceful: bool) -> None:
  547. """Trigger satellite disconnected event."""
  548. try:
  549. if self._application_ref and self._application_ref():
  550. app = self._application_ref()
  551. event_handler = app.get_event_handler()
  552. from ..events import EventDataFactory
  553. disconnect_data = EventDataFactory.create_event_data(
  554. "satellite_disconnected",
  555. satellite_id=self.satellite_id,
  556. mac_address=self.mac_address,
  557. room_id=self.room_id,
  558. alias_name=self.alias_name,
  559. reason=reason,
  560. graceful_disconnect=graceful,
  561. disconnection_time=datetime.now(timezone.utc).isoformat()
  562. )
  563. event_handler.trigger_event("satellite_disconnected", disconnect_data)
  564. except Exception as e:
  565. pprint(f"Error triggering disconnect event for {self.alias_name}: {e}")
  566. def to_dict(self) -> Dict[str, Any]:
  567. """Convert satellite to dictionary representation."""
  568. return {
  569. "info": self._info.to_dict(),
  570. "status": self._status.value,
  571. "audio_ports": self._audio_ports.to_dict(),
  572. "statistics": self._stats.copy(),
  573. "connected_at": self._connected_at.isoformat() if self._connected_at else None,
  574. }
  575. def __str__(self) -> str:
  576. """String representation of the satellite."""
  577. return f"Satellite(alias='{self.alias_name}', room='{self.room_id}', status='{self._status.value}')"
  578. def __repr__(self) -> str:
  579. """Detailed representation of the satellite."""
  580. return (f"Satellite(id='{self.satellite_id}', alias='{self.alias_name}', "
  581. f"room='{self.room_id}', mac='{self.mac_address}', status='{self._status.value}')")
  582. # Factory functions
  583. def create_satellite_info(
  584. satellite_id: str,
  585. mac_address: str,
  586. room_id: str,
  587. alias_name: str,
  588. ip_address: Optional[str] = None,
  589. version: str = "1.0.0",
  590. capabilities: Optional[List[SatelliteCapability]] = None,
  591. device_info: Optional[Dict[str, Any]] = None
  592. ) -> SatelliteInfo:
  593. """
  594. Create a SatelliteInfo instance.
  595. Args:
  596. satellite_id: Unique satellite identifier
  597. mac_address: MAC address
  598. room_id: Room identifier
  599. alias_name: Human-readable alias
  600. ip_address: IP address
  601. version: Software version
  602. capabilities: List of capabilities
  603. device_info: Additional device information
  604. Returns:
  605. SatelliteInfo: Configured satellite information
  606. """
  607. return SatelliteInfo(
  608. satellite_id=satellite_id,
  609. mac_address=mac_address,
  610. room_id=room_id,
  611. alias_name=alias_name,
  612. ip_address=ip_address,
  613. version=version,
  614. capabilities=capabilities or [],
  615. device_info=device_info or {},
  616. registered_at=datetime.now(timezone.utc)
  617. )
  618. def create_satellite(
  619. satellite_info: SatelliteInfo,
  620. application,
  621. network_manager=None
  622. ) -> Satellite:
  623. """
  624. Create a Satellite instance.
  625. Args:
  626. satellite_info: Satellite information
  627. application: Application container
  628. network_manager: Network manager for communication
  629. Returns:
  630. Satellite: Configured satellite instance
  631. """
  632. return Satellite(satellite_info, application, network_manager)
  633. # Module exports
  634. __all__ = [
  635. "Satellite",
  636. "SatelliteError",
  637. "SatelliteConnectionError",
  638. "SatelliteCommunicationError",
  639. "SatelliteStatus",
  640. "SatelliteCapability",
  641. "AudioPortInfo",
  642. "SatelliteInfo",
  643. "create_satellite_info",
  644. "create_satellite",
  645. ]