satellite.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. """
  2. Satellite Management Command Classes
  3. This module implements commands for satellite (client) management in the
  4. Trixy system. These commands handle:
  5. - Satellite registration and authentication
  6. - Connection establishment and teardown
  7. - Status monitoring and health checks
  8. - Room and alias management
  9. These commands implement the satellite workflow specified in CLAUDE.md:
  10. 1. Registration process (with 60-second timeout)
  11. 2. Connection process with MAC validation
  12. 3. Status updates and disconnection handling
  13. """
  14. import time
  15. from typing import Optional, Dict, Any, List
  16. from dataclasses import dataclass, field
  17. from .base import TrixyCommand, CommandResponse, ValidationError, CommandStatus
  18. def pprint(message: str) -> None:
  19. """Satellite command logging function."""
  20. print(f"[CMD.SATELLITE] {message}")
  21. @dataclass
  22. class SatelliteHelloCommand(TrixyCommand):
  23. """
  24. Initial hello command from satellite to server.
  25. This is the first command sent by a satellite when attempting to connect.
  26. It provides basic identification information for the satellite.
  27. """
  28. room_id: str = field(default="")
  29. alias: str = field(default="")
  30. mac_address: str = field(default="")
  31. version: str = field(default="1.0.0")
  32. capabilities: List[str] = field(default_factory=list)
  33. def validate(self) -> bool:
  34. """Validate satellite hello command."""
  35. if not self.room_id:
  36. raise ValidationError("SatelliteHelloCommand must have a room_id")
  37. if not self.alias:
  38. raise ValidationError("SatelliteHelloCommand must have an alias")
  39. if not self.mac_address:
  40. raise ValidationError("SatelliteHelloCommand must have a mac_address")
  41. # Validate MAC address format (basic check)
  42. if len(self.mac_address.split(':')) != 6:
  43. raise ValidationError("MAC address must be in format aa:bb:cc:dd:ee:ff")
  44. return True
  45. def get_command_name(self) -> str:
  46. """Get command name."""
  47. return "SatelliteHelloCommand"
  48. def get_response_class(self):
  49. """Expect a hello response."""
  50. return SatelliteHelloResponse
  51. def get_timeout(self) -> float:
  52. """Timeout for hello command."""
  53. return 15.0
  54. def get_priority(self) -> int:
  55. """High priority for connection establishment."""
  56. return 10
  57. @dataclass
  58. class SatelliteRegistrationCommand(TrixyCommand):
  59. """
  60. Command to register a new satellite with the server.
  61. This command is used during the registration process when a satellite
  62. is not yet known to the server. The server must be in "registration mode"
  63. to accept this command.
  64. """
  65. room_id: str = field(default="")
  66. alias: str = field(default="")
  67. mac_address: str = field(default="")
  68. device_info: Dict[str, Any] = field(default_factory=dict)
  69. audio_capabilities: Dict[str, Any] = field(default_factory=dict)
  70. def validate(self) -> bool:
  71. """Validate satellite registration command."""
  72. if not self.room_id:
  73. raise ValidationError("SatelliteRegistrationCommand must have a room_id")
  74. if not self.alias:
  75. raise ValidationError("SatelliteRegistrationCommand must have an alias")
  76. if not self.mac_address:
  77. raise ValidationError("SatelliteRegistrationCommand must have a mac_address")
  78. # Validate MAC address format
  79. if len(self.mac_address.split(':')) != 6:
  80. raise ValidationError("MAC address must be in format aa:bb:cc:dd:ee:ff")
  81. return True
  82. def get_command_name(self) -> str:
  83. """Get command name."""
  84. return "SatelliteRegistrationCommand"
  85. def get_response_class(self):
  86. """Expect a registration response."""
  87. return SatelliteRegistrationResponse
  88. def get_timeout(self) -> float:
  89. """Timeout for registration (must complete within registration window)."""
  90. return 60.0
  91. def get_priority(self) -> int:
  92. """Very high priority for registration."""
  93. return 5
  94. @dataclass
  95. class SatelliteConnectionCommand(TrixyCommand):
  96. """
  97. Command to establish connection with a registered satellite.
  98. This command is sent after successful registration to establish
  99. the working connection and set up audio streams.
  100. """
  101. satellite_id: str = field(default="")
  102. mac_address: str = field(default="")
  103. requested_audio_ports: Dict[str, int] = field(default_factory=dict)
  104. session_id: Optional[str] = field(default=None)
  105. def validate(self) -> bool:
  106. """Validate satellite connection command."""
  107. if not self.satellite_id:
  108. raise ValidationError("SatelliteConnectionCommand must have a satellite_id")
  109. if not self.mac_address:
  110. raise ValidationError("SatelliteConnectionCommand must have a mac_address")
  111. # Validate MAC address format
  112. if len(self.mac_address.split(':')) != 6:
  113. raise ValidationError("MAC address must be in format aa:bb:cc:dd:ee:ff")
  114. return True
  115. def get_command_name(self) -> str:
  116. """Get command name."""
  117. return "SatelliteConnectionCommand"
  118. def get_response_class(self):
  119. """Expect a connection response."""
  120. return SatelliteConnectionResponse
  121. def get_timeout(self) -> float:
  122. """Timeout for connection establishment."""
  123. return 30.0
  124. def get_priority(self) -> int:
  125. """High priority for connection."""
  126. return 15
  127. @dataclass
  128. class SatelliteDisconnectCommand(TrixyCommand):
  129. """
  130. Command to gracefully disconnect a satellite.
  131. This command can be sent by either the satellite (to disconnect itself)
  132. or the server (to disconnect a satellite).
  133. """
  134. satellite_id: str = field(default="")
  135. reason: str = field(default="normal_disconnect")
  136. graceful: bool = field(default=True)
  137. def validate(self) -> bool:
  138. """Validate satellite disconnect command."""
  139. if not self.satellite_id:
  140. raise ValidationError("SatelliteDisconnectCommand must have a satellite_id")
  141. return True
  142. def get_command_name(self) -> str:
  143. """Get command name."""
  144. return "SatelliteDisconnectCommand"
  145. def requires_response(self) -> bool:
  146. """Disconnect may not always get a response."""
  147. return self.graceful
  148. def get_timeout(self) -> float:
  149. """Short timeout for disconnect."""
  150. return 10.0
  151. def get_priority(self) -> int:
  152. """High priority for disconnect."""
  153. return 20
  154. @dataclass
  155. class SatelliteStatusCommand(TrixyCommand):
  156. """
  157. Command to request or provide satellite status information.
  158. This can be used for health monitoring and status reporting.
  159. """
  160. satellite_id: str = field(default="")
  161. status_type: str = field(default="general") # general, audio, network, health
  162. status_data: Optional[Dict[str, Any]] = field(default=None)
  163. def validate(self) -> bool:
  164. """Validate satellite status command."""
  165. valid_types = ["general", "audio", "network", "health", "capabilities"]
  166. if self.status_type not in valid_types:
  167. raise ValidationError(f"status_type must be one of: {valid_types}")
  168. return True
  169. def get_command_name(self) -> str:
  170. """Get command name."""
  171. return "SatelliteStatusCommand"
  172. def get_timeout(self) -> float:
  173. """Timeout for status request."""
  174. return 15.0
  175. def get_priority(self) -> int:
  176. """Normal priority for status."""
  177. return 50
  178. # Response Classes
  179. @dataclass
  180. class SatelliteHelloResponse(CommandResponse):
  181. """Response to satellite hello command."""
  182. accepted: bool = field(default=False)
  183. server_version: str = field(default="1.0.0")
  184. assigned_satellite_id: Optional[str] = field(default=None)
  185. registration_required: bool = field(default=False)
  186. next_steps: List[str] = field(default_factory=list)
  187. def validate(self) -> bool:
  188. """Validate hello response."""
  189. return True
  190. def get_response_name(self) -> str:
  191. """Get response name."""
  192. return "SatelliteHelloResponse"
  193. @dataclass
  194. class SatelliteRegistrationResponse(CommandResponse):
  195. """Response to satellite registration command."""
  196. registration_accepted: bool = field(default=False)
  197. satellite_id: Optional[str] = field(default=None)
  198. registration_file_created: bool = field(default=False)
  199. next_steps: List[str] = field(default_factory=list)
  200. rejection_reason: Optional[str] = field(default=None)
  201. def validate(self) -> bool:
  202. """Validate registration response."""
  203. if not self.registration_accepted and not self.rejection_reason:
  204. raise ValidationError("Registration rejection must include reason")
  205. return True
  206. def get_response_name(self) -> str:
  207. """Get response name."""
  208. return "SatelliteRegistrationResponse"
  209. @dataclass
  210. class SatelliteConnectionResponse(CommandResponse):
  211. """Response to satellite connection command."""
  212. connection_accepted: bool = field(default=False)
  213. assigned_audio_ports: Dict[str, int] = field(default_factory=dict)
  214. session_id: Optional[str] = field(default=None)
  215. server_capabilities: List[str] = field(default_factory=list)
  216. rejection_reason: Optional[str] = field(default=None)
  217. def validate(self) -> bool:
  218. """Validate connection response."""
  219. if not self.connection_accepted and not self.rejection_reason:
  220. raise ValidationError("Connection rejection must include reason")
  221. return True
  222. def get_response_name(self) -> str:
  223. """Get response name."""
  224. return "SatelliteConnectionResponse"
  225. # Utility functions for satellite commands
  226. def create_satellite_hello(
  227. room_id: str,
  228. alias: str,
  229. mac_address: str,
  230. version: str = "1.0.0",
  231. capabilities: Optional[List[str]] = None
  232. ) -> SatelliteHelloCommand:
  233. """
  234. Create a satellite hello command.
  235. Args:
  236. room_id: Room identifier
  237. alias: Satellite alias/name
  238. mac_address: MAC address
  239. version: Client version
  240. capabilities: List of capabilities
  241. Returns:
  242. SatelliteHelloCommand: Hello command
  243. """
  244. return SatelliteHelloCommand(
  245. room_id=room_id,
  246. alias=alias,
  247. mac_address=mac_address,
  248. version=version,
  249. capabilities=capabilities or []
  250. )
  251. def create_satellite_registration(
  252. room_id: str,
  253. alias: str,
  254. mac_address: str,
  255. device_info: Optional[Dict[str, Any]] = None,
  256. audio_capabilities: Optional[Dict[str, Any]] = None
  257. ) -> SatelliteRegistrationCommand:
  258. """
  259. Create a satellite registration command.
  260. Args:
  261. room_id: Room identifier
  262. alias: Satellite alias/name
  263. mac_address: MAC address
  264. device_info: Device information
  265. audio_capabilities: Audio capabilities
  266. Returns:
  267. SatelliteRegistrationCommand: Registration command
  268. """
  269. return SatelliteRegistrationCommand(
  270. room_id=room_id,
  271. alias=alias,
  272. mac_address=mac_address,
  273. device_info=device_info or {},
  274. audio_capabilities=audio_capabilities or {}
  275. )
  276. def create_satellite_connection(
  277. satellite_id: str,
  278. mac_address: str,
  279. requested_audio_ports: Optional[Dict[str, int]] = None,
  280. session_id: Optional[str] = None
  281. ) -> SatelliteConnectionCommand:
  282. """
  283. Create a satellite connection command.
  284. Args:
  285. satellite_id: Satellite identifier
  286. mac_address: MAC address
  287. requested_audio_ports: Requested audio port mapping
  288. session_id: Optional session ID
  289. Returns:
  290. SatelliteConnectionCommand: Connection command
  291. """
  292. return SatelliteConnectionCommand(
  293. satellite_id=satellite_id,
  294. mac_address=mac_address,
  295. requested_audio_ports=requested_audio_ports or {},
  296. session_id=session_id
  297. )
  298. def create_satellite_disconnect(
  299. satellite_id: str,
  300. reason: str = "normal_disconnect",
  301. graceful: bool = True
  302. ) -> SatelliteDisconnectCommand:
  303. """
  304. Create a satellite disconnect command.
  305. Args:
  306. satellite_id: Satellite identifier
  307. reason: Disconnect reason
  308. graceful: Whether disconnect is graceful
  309. Returns:
  310. SatelliteDisconnectCommand: Disconnect command
  311. """
  312. return SatelliteDisconnectCommand(
  313. satellite_id=satellite_id,
  314. reason=reason,
  315. graceful=graceful
  316. )
  317. def create_hello_response(
  318. command_id: str,
  319. accepted: bool,
  320. server_version: str = "1.0.0",
  321. assigned_satellite_id: Optional[str] = None,
  322. registration_required: bool = False,
  323. next_steps: Optional[List[str]] = None
  324. ) -> SatelliteHelloResponse:
  325. """
  326. Create a satellite hello response.
  327. Args:
  328. command_id: Original command ID
  329. accepted: Whether hello is accepted
  330. server_version: Server version
  331. assigned_satellite_id: Assigned satellite ID
  332. registration_required: Whether registration is required
  333. next_steps: List of next steps
  334. Returns:
  335. SatelliteHelloResponse: Hello response
  336. """
  337. status = CommandStatus.SUCCESS if accepted else CommandStatus.ERROR
  338. return SatelliteHelloResponse(
  339. command_id=command_id,
  340. status=status,
  341. accepted=accepted,
  342. server_version=server_version,
  343. assigned_satellite_id=assigned_satellite_id,
  344. registration_required=registration_required,
  345. next_steps=next_steps or []
  346. )
  347. def create_registration_response(
  348. command_id: str,
  349. registration_accepted: bool,
  350. satellite_id: Optional[str] = None,
  351. rejection_reason: Optional[str] = None
  352. ) -> SatelliteRegistrationResponse:
  353. """
  354. Create a satellite registration response.
  355. Args:
  356. command_id: Original command ID
  357. registration_accepted: Whether registration is accepted
  358. satellite_id: Assigned satellite ID
  359. rejection_reason: Reason for rejection if not accepted
  360. Returns:
  361. SatelliteRegistrationResponse: Registration response
  362. """
  363. status = CommandStatus.SUCCESS if registration_accepted else CommandStatus.ERROR
  364. return SatelliteRegistrationResponse(
  365. command_id=command_id,
  366. status=status,
  367. registration_accepted=registration_accepted,
  368. satellite_id=satellite_id,
  369. registration_file_created=registration_accepted,
  370. rejection_reason=rejection_reason
  371. )
  372. def create_connection_response(
  373. command_id: str,
  374. connection_accepted: bool,
  375. assigned_audio_ports: Optional[Dict[str, int]] = None,
  376. session_id: Optional[str] = None,
  377. rejection_reason: Optional[str] = None
  378. ) -> SatelliteConnectionResponse:
  379. """
  380. Create a satellite connection response.
  381. Args:
  382. command_id: Original command ID
  383. connection_accepted: Whether connection is accepted
  384. assigned_audio_ports: Assigned audio port mapping
  385. session_id: Session ID
  386. rejection_reason: Reason for rejection if not accepted
  387. Returns:
  388. SatelliteConnectionResponse: Connection response
  389. """
  390. status = CommandStatus.SUCCESS if connection_accepted else CommandStatus.ERROR
  391. return SatelliteConnectionResponse(
  392. command_id=command_id,
  393. status=status,
  394. connection_accepted=connection_accepted,
  395. assigned_audio_ports=assigned_audio_ports or {},
  396. session_id=session_id,
  397. rejection_reason=rejection_reason
  398. )
  399. # Module exports
  400. __all__ = [
  401. "SatelliteHelloCommand",
  402. "SatelliteRegistrationCommand",
  403. "SatelliteConnectionCommand",
  404. "SatelliteDisconnectCommand",
  405. "SatelliteStatusCommand",
  406. "SatelliteHelloResponse",
  407. "SatelliteRegistrationResponse",
  408. "SatelliteConnectionResponse",
  409. "create_satellite_hello",
  410. "create_satellite_registration",
  411. "create_satellite_connection",
  412. "create_satellite_disconnect",
  413. "create_hello_response",
  414. "create_registration_response",
  415. "create_connection_response",
  416. ]