foodforthought-cli 0.2.1__py3-none-any.whl → 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. ate/__init__.py +1 -1
  2. ate/bridge_server.py +622 -0
  3. ate/cli.py +2625 -242
  4. ate/compatibility.py +580 -0
  5. ate/generators/__init__.py +19 -0
  6. ate/generators/docker_generator.py +461 -0
  7. ate/generators/hardware_config.py +469 -0
  8. ate/generators/ros2_generator.py +617 -0
  9. ate/generators/skill_generator.py +783 -0
  10. ate/marketplace.py +524 -0
  11. ate/mcp_server.py +1341 -107
  12. ate/primitives.py +1016 -0
  13. ate/robot_setup.py +2222 -0
  14. ate/skill_schema.py +537 -0
  15. ate/telemetry/__init__.py +33 -0
  16. ate/telemetry/cli.py +455 -0
  17. ate/telemetry/collector.py +444 -0
  18. ate/telemetry/context.py +318 -0
  19. ate/telemetry/fleet_agent.py +419 -0
  20. ate/telemetry/formats/__init__.py +18 -0
  21. ate/telemetry/formats/hdf5_serializer.py +503 -0
  22. ate/telemetry/formats/mcap_serializer.py +457 -0
  23. ate/telemetry/types.py +334 -0
  24. foodforthought_cli-0.2.4.dist-info/METADATA +300 -0
  25. foodforthought_cli-0.2.4.dist-info/RECORD +44 -0
  26. foodforthought_cli-0.2.4.dist-info/top_level.txt +6 -0
  27. mechdog_labeled/__init__.py +3 -0
  28. mechdog_labeled/primitives.py +113 -0
  29. mechdog_labeled/servo_map.py +209 -0
  30. mechdog_output/__init__.py +3 -0
  31. mechdog_output/primitives.py +59 -0
  32. mechdog_output/servo_map.py +203 -0
  33. test_autodetect/__init__.py +3 -0
  34. test_autodetect/primitives.py +113 -0
  35. test_autodetect/servo_map.py +209 -0
  36. test_full_auto/__init__.py +3 -0
  37. test_full_auto/primitives.py +113 -0
  38. test_full_auto/servo_map.py +209 -0
  39. test_smart_detect/__init__.py +3 -0
  40. test_smart_detect/primitives.py +113 -0
  41. test_smart_detect/servo_map.py +209 -0
  42. foodforthought_cli-0.2.1.dist-info/METADATA +0 -151
  43. foodforthought_cli-0.2.1.dist-info/RECORD +0 -9
  44. foodforthought_cli-0.2.1.dist-info/top_level.txt +0 -1
  45. {foodforthought_cli-0.2.1.dist-info → foodforthought_cli-0.2.4.dist-info}/WHEEL +0 -0
  46. {foodforthought_cli-0.2.1.dist-info → foodforthought_cli-0.2.4.dist-info}/entry_points.txt +0 -0
ate/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  """FoodforThought CLI (ATE) - GitHub-like interface for robotics repositories"""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.4"
4
4
 
ate/bridge_server.py ADDED
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ATE Bridge Server - WebSocket server for Artifex integration.
4
+
5
+ This module provides a WebSocket server that allows Artifex Desktop to:
6
+ 1. Discover connected robots
7
+ 2. Connect to robot hardware
8
+ 3. Control servos and read states
9
+ 4. Execute trajectories
10
+ 5. Deploy and run skills
11
+ 6. Sync URDF configurations
12
+
13
+ Usage:
14
+ ate bridge # Start on default port 8765
15
+ ate bridge --port 9000 # Start on custom port
16
+ """
17
+
18
+ import asyncio
19
+ import json
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from typing import Dict, Any, Callable, Set, Optional, List
23
+ from pathlib import Path
24
+
25
+ # Optional imports
26
+ try:
27
+ import websockets
28
+ from websockets.server import WebSocketServerProtocol
29
+ HAS_WEBSOCKETS = True
30
+ except ImportError:
31
+ HAS_WEBSOCKETS = False
32
+
33
+ try:
34
+ import serial
35
+ import serial.tools.list_ports
36
+ HAS_SERIAL = True
37
+ except ImportError:
38
+ HAS_SERIAL = False
39
+
40
+
41
+ @dataclass
42
+ class ServoState:
43
+ """Current state of a servo."""
44
+ id: int
45
+ position: float = 0.0 # radians
46
+ velocity: float = 0.0
47
+ temperature: float = 0.0
48
+ load: float = 0.0
49
+ voltage: float = 0.0
50
+ torque_enabled: bool = False
51
+
52
+
53
+ @dataclass
54
+ class RobotConnection:
55
+ """Active robot connection."""
56
+ port: str
57
+ baud_rate: int
58
+ robot_type: str
59
+ servo_ids: List[int]
60
+ servo_states: Dict[int, ServoState] = field(default_factory=dict)
61
+ serial_conn: Any = None
62
+
63
+
64
+ @dataclass
65
+ class PortInfo:
66
+ """Information about a serial port."""
67
+ path: str
68
+ description: str
69
+ vid: Optional[int] = None
70
+ pid: Optional[int] = None
71
+ serial_number: Optional[str] = None
72
+ manufacturer: Optional[str] = None
73
+
74
+
75
+ class ATEBridgeServer:
76
+ """
77
+ WebSocket server for Artifex-ATE integration.
78
+
79
+ Handles bidirectional communication between Artifex Desktop and
80
+ physical robot hardware through a WebSocket connection.
81
+ """
82
+
83
+ def __init__(self, port: int = 8765, verbose: bool = False):
84
+ self.port = port
85
+ self.verbose = verbose
86
+ self.connections: Set[WebSocketServerProtocol] = set()
87
+ self.robot: Optional[RobotConnection] = None
88
+ self.handlers: Dict[str, Callable] = {}
89
+ self._running = False
90
+ self._broadcast_task: Optional[asyncio.Task] = None
91
+ self._setup_handlers()
92
+
93
+ def _setup_handlers(self):
94
+ """Register request handlers."""
95
+ self.handlers = {
96
+ # Connection management
97
+ "ping": self._handle_ping,
98
+ "discover_ports": self._handle_discover_ports,
99
+ "connect_robot": self._handle_connect_robot,
100
+ "disconnect_robot": self._handle_disconnect_robot,
101
+
102
+ # Servo control
103
+ "get_servo_state": self._handle_get_servo_state,
104
+ "set_servo_position": self._handle_set_servo_position,
105
+ "set_servo_torque": self._handle_set_servo_torque,
106
+
107
+ # Trajectory execution
108
+ "execute_trajectory": self._handle_execute_trajectory,
109
+
110
+ # Configuration sync
111
+ "get_robot_config": self._handle_get_robot_config,
112
+ "sync_urdf": self._handle_sync_urdf,
113
+ "sync_config": self._handle_sync_config,
114
+
115
+ # Skill management
116
+ "list_skills": self._handle_list_skills,
117
+ "deploy_skill": self._handle_deploy_skill,
118
+ "run_skill": self._handle_run_skill,
119
+ }
120
+
121
+ def _log(self, message: str):
122
+ """Log a message if verbose mode is enabled."""
123
+ if self.verbose:
124
+ print(f"[ATE Bridge] {message}")
125
+
126
+ async def handle_connection(self, websocket: WebSocketServerProtocol):
127
+ """Handle a new WebSocket connection."""
128
+ self.connections.add(websocket)
129
+ client_addr = f"{websocket.remote_address[0]}:{websocket.remote_address[1]}"
130
+ self._log(f"Client connected: {client_addr}")
131
+
132
+ try:
133
+ async for message in websocket:
134
+ try:
135
+ request = json.loads(message)
136
+ response = await self._handle_request(request)
137
+ await websocket.send(json.dumps(response))
138
+ except json.JSONDecodeError as e:
139
+ await websocket.send(json.dumps({
140
+ "error": f"Invalid JSON: {str(e)}"
141
+ }))
142
+ except websockets.exceptions.ConnectionClosed:
143
+ self._log(f"Client disconnected: {client_addr}")
144
+ finally:
145
+ self.connections.discard(websocket)
146
+
147
+ async def _handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
148
+ """Route request to appropriate handler."""
149
+ action = request.get("action")
150
+ params = request.get("params", {})
151
+ request_id = request.get("id")
152
+
153
+ self._log(f"Request: {action} (id={request_id})")
154
+
155
+ handler = self.handlers.get(action)
156
+ if not handler:
157
+ return {
158
+ "id": request_id,
159
+ "error": f"Unknown action: {action}"
160
+ }
161
+
162
+ try:
163
+ result = await handler(params)
164
+ return {"id": request_id, "result": result}
165
+ except Exception as e:
166
+ self._log(f"Error handling {action}: {str(e)}")
167
+ return {"id": request_id, "error": str(e)}
168
+
169
+ # =========================================================================
170
+ # Connection Handlers
171
+ # =========================================================================
172
+
173
+ async def _handle_ping(self, params: Dict) -> Dict:
174
+ """Simple ping for connection testing."""
175
+ return {"pong": True, "timestamp": time.time()}
176
+
177
+ async def _handle_discover_ports(self, params: Dict) -> Dict:
178
+ """Discover available serial ports with robots."""
179
+ if not HAS_SERIAL:
180
+ return {"ports": [], "error": "pyserial not installed"}
181
+
182
+ ports = []
183
+ for port in serial.tools.list_ports.comports():
184
+ port_info = PortInfo(
185
+ path=port.device,
186
+ description=port.description or "",
187
+ vid=port.vid,
188
+ pid=port.pid,
189
+ serial_number=port.serial_number,
190
+ manufacturer=port.manufacturer
191
+ )
192
+ ports.append({
193
+ "path": port_info.path,
194
+ "description": port_info.description,
195
+ "vid": port_info.vid,
196
+ "pid": port_info.pid,
197
+ "serialNumber": port_info.serial_number,
198
+ "manufacturer": port_info.manufacturer
199
+ })
200
+
201
+ return {"ports": ports}
202
+
203
+ async def _handle_connect_robot(self, params: Dict) -> Dict:
204
+ """Connect to robot on specified port."""
205
+ port = params.get("port")
206
+ robot_type = params.get("robot_type", "auto")
207
+ baud_rate = params.get("baud_rate", 115200)
208
+
209
+ if not port:
210
+ raise ValueError("Port is required")
211
+
212
+ if not HAS_SERIAL:
213
+ # Mock connection for testing without hardware
214
+ self._log(f"Mock connecting to {port}")
215
+ self.robot = RobotConnection(
216
+ port=port,
217
+ baud_rate=baud_rate,
218
+ robot_type=robot_type,
219
+ servo_ids=[1, 2, 3, 4, 5, 6], # Mock 6 servos
220
+ )
221
+ # Initialize mock servo states
222
+ for sid in self.robot.servo_ids:
223
+ self.robot.servo_states[sid] = ServoState(id=sid)
224
+
225
+ return {
226
+ "connected": True,
227
+ "port": port,
228
+ "robotType": robot_type,
229
+ "servos": [
230
+ {"id": sid, "position": 0.0, "velocity": 0.0}
231
+ for sid in self.robot.servo_ids
232
+ ]
233
+ }
234
+
235
+ # Real connection
236
+ try:
237
+ ser = serial.Serial(port, baud_rate, timeout=1)
238
+
239
+ # Scan for servos
240
+ servo_ids = await self._scan_servos(ser)
241
+
242
+ self.robot = RobotConnection(
243
+ port=port,
244
+ baud_rate=baud_rate,
245
+ robot_type=robot_type,
246
+ servo_ids=servo_ids,
247
+ serial_conn=ser
248
+ )
249
+
250
+ # Initialize servo states
251
+ for sid in servo_ids:
252
+ self.robot.servo_states[sid] = ServoState(id=sid)
253
+
254
+ self._log(f"Connected to {port}, found {len(servo_ids)} servos")
255
+
256
+ return {
257
+ "connected": True,
258
+ "port": port,
259
+ "robotType": robot_type,
260
+ "servos": [
261
+ {"id": sid, "position": 0.0, "velocity": 0.0}
262
+ for sid in servo_ids
263
+ ]
264
+ }
265
+ except serial.SerialException as e:
266
+ raise ValueError(f"Failed to connect: {str(e)}")
267
+
268
+ async def _scan_servos(self, ser: Any) -> List[int]:
269
+ """Scan for connected servos."""
270
+ # This is a placeholder - actual implementation depends on protocol
271
+ # For HiWonder servos, we'd send a broadcast read and collect responses
272
+ found_ids = []
273
+
274
+ # Try scanning IDs 1-20
275
+ for servo_id in range(1, 21):
276
+ # Send ping command (protocol-specific)
277
+ # For now, return mock IDs
278
+ pass
279
+
280
+ # Return mock servo IDs for now
281
+ return [1, 2, 3, 4, 5, 6]
282
+
283
+ async def _handle_disconnect_robot(self, params: Dict) -> Dict:
284
+ """Disconnect from robot."""
285
+ if self.robot:
286
+ if self.robot.serial_conn:
287
+ self.robot.serial_conn.close()
288
+ self.robot = None
289
+ self._log("Robot disconnected")
290
+
291
+ return {"disconnected": True}
292
+
293
+ # =========================================================================
294
+ # Servo Control Handlers
295
+ # =========================================================================
296
+
297
+ async def _handle_get_servo_state(self, params: Dict) -> Dict:
298
+ """Get current state of all servos."""
299
+ if not self.robot:
300
+ raise ValueError("No robot connected")
301
+
302
+ # Read current servo states
303
+ servo_states = []
304
+ for sid, state in self.robot.servo_states.items():
305
+ servo_states.append({
306
+ "id": sid,
307
+ "position": state.position,
308
+ "velocity": state.velocity,
309
+ "temperature": state.temperature,
310
+ "load": state.load,
311
+ "voltage": state.voltage,
312
+ "torqueEnabled": state.torque_enabled
313
+ })
314
+
315
+ return {"servos": servo_states}
316
+
317
+ async def _handle_set_servo_position(self, params: Dict) -> Dict:
318
+ """Set position of a single servo."""
319
+ if not self.robot:
320
+ raise ValueError("No robot connected")
321
+
322
+ servo_id = params.get("servo_id")
323
+ position = params.get("position") # radians
324
+ speed = params.get("speed", 1.0) # 0-1 normalized
325
+
326
+ if servo_id is None or position is None:
327
+ raise ValueError("servo_id and position are required")
328
+
329
+ if servo_id not in self.robot.servo_states:
330
+ raise ValueError(f"Unknown servo ID: {servo_id}")
331
+
332
+ # Update state (real implementation would send to hardware)
333
+ self.robot.servo_states[servo_id].position = position
334
+ self._log(f"Set servo {servo_id} to {position:.3f} rad")
335
+
336
+ return {"success": True, "servoId": servo_id, "position": position}
337
+
338
+ async def _handle_set_servo_torque(self, params: Dict) -> Dict:
339
+ """Enable/disable torque on a servo or all servos."""
340
+ if not self.robot:
341
+ raise ValueError("No robot connected")
342
+
343
+ servo_id = params.get("servo_id") # None = all servos
344
+ enabled = params.get("enabled", True)
345
+
346
+ if servo_id is not None:
347
+ if servo_id not in self.robot.servo_states:
348
+ raise ValueError(f"Unknown servo ID: {servo_id}")
349
+ self.robot.servo_states[servo_id].torque_enabled = enabled
350
+ affected = [servo_id]
351
+ else:
352
+ for state in self.robot.servo_states.values():
353
+ state.torque_enabled = enabled
354
+ affected = list(self.robot.servo_states.keys())
355
+
356
+ return {"success": True, "servoIds": affected, "enabled": enabled}
357
+
358
+ # =========================================================================
359
+ # Trajectory Handlers
360
+ # =========================================================================
361
+
362
+ async def _handle_execute_trajectory(self, params: Dict) -> Dict:
363
+ """Execute a trajectory on the robot."""
364
+ if not self.robot:
365
+ raise ValueError("No robot connected")
366
+
367
+ trajectory = params.get("trajectory", [])
368
+ if not trajectory:
369
+ raise ValueError("Empty trajectory")
370
+
371
+ self._log(f"Executing trajectory with {len(trajectory)} waypoints")
372
+
373
+ start_time = time.time()
374
+
375
+ for i, waypoint in enumerate(trajectory):
376
+ wp_time = waypoint.get("time", 0)
377
+ positions = waypoint.get("positions", {})
378
+
379
+ # Wait until waypoint time
380
+ elapsed = time.time() - start_time
381
+ if wp_time > elapsed:
382
+ await asyncio.sleep(wp_time - elapsed)
383
+
384
+ # Apply positions
385
+ for joint_name, position in positions.items():
386
+ # Map joint name to servo ID (simplified)
387
+ servo_id = int(joint_name.split("_")[-1]) if "_" in joint_name else 1
388
+ if servo_id in self.robot.servo_states:
389
+ self.robot.servo_states[servo_id].position = position
390
+
391
+ # Broadcast state update
392
+ await self._broadcast_servo_state()
393
+
394
+ duration = time.time() - start_time
395
+ self._log(f"Trajectory completed in {duration:.2f}s")
396
+
397
+ return {"success": True, "duration": duration}
398
+
399
+ # =========================================================================
400
+ # Configuration Handlers
401
+ # =========================================================================
402
+
403
+ async def _handle_get_robot_config(self, params: Dict) -> Dict:
404
+ """Get current robot configuration."""
405
+ if not self.robot:
406
+ raise ValueError("No robot connected")
407
+
408
+ return {
409
+ "port": self.robot.port,
410
+ "baudRate": self.robot.baud_rate,
411
+ "robotType": self.robot.robot_type,
412
+ "servoIds": self.robot.servo_ids
413
+ }
414
+
415
+ async def _handle_sync_urdf(self, params: Dict) -> Dict:
416
+ """Sync URDF from Artifex to create joint mapping."""
417
+ urdf_content = params.get("urdf")
418
+ if not urdf_content:
419
+ raise ValueError("URDF content is required")
420
+
421
+ # Parse URDF to extract joint names
422
+ # This is a simplified parser - real implementation would use urdfpy
423
+ import re
424
+ joint_pattern = r'<joint\s+name="([^"]+)"'
425
+ joint_names = re.findall(joint_pattern, urdf_content)
426
+
427
+ # Create suggested mapping
428
+ mapping = []
429
+ for i, joint_name in enumerate(joint_names):
430
+ if self.robot and i < len(self.robot.servo_ids):
431
+ mapping.append({
432
+ "jointName": joint_name,
433
+ "servoId": self.robot.servo_ids[i],
434
+ "inverted": False,
435
+ "offset": 0.0
436
+ })
437
+
438
+ return {"mapping": mapping, "joints": joint_names}
439
+
440
+ async def _handle_sync_config(self, params: Dict) -> Dict:
441
+ """Sync full robot configuration from Artifex."""
442
+ config = params.get("config", {})
443
+
444
+ # Store configuration
445
+ self._log(f"Received config for robot: {config.get('robot_name', 'unnamed')}")
446
+
447
+ # Apply joint mapping if provided
448
+ if "joints" in config:
449
+ for joint in config["joints"]:
450
+ servo_id = joint.get("servo_id")
451
+ if servo_id and self.robot and servo_id in self.robot.servo_states:
452
+ # Store any additional config per servo
453
+ pass
454
+
455
+ return {"success": True}
456
+
457
+ # =========================================================================
458
+ # Skill Handlers
459
+ # =========================================================================
460
+
461
+ async def _handle_list_skills(self, params: Dict) -> Dict:
462
+ """List available skills."""
463
+ # Look for skills in the current directory
464
+ skills = []
465
+ skills_dir = Path("./skills")
466
+
467
+ if skills_dir.exists():
468
+ for skill_path in skills_dir.glob("*/skill.json"):
469
+ try:
470
+ with open(skill_path) as f:
471
+ skill_meta = json.load(f)
472
+ skills.append({
473
+ "name": skill_meta.get("name", skill_path.parent.name),
474
+ "version": skill_meta.get("version", "1.0.0"),
475
+ "description": skill_meta.get("description", ""),
476
+ "parameters": skill_meta.get("parameters", [])
477
+ })
478
+ except Exception:
479
+ pass
480
+
481
+ return {"skills": skills}
482
+
483
+ async def _handle_deploy_skill(self, params: Dict) -> Dict:
484
+ """Deploy a skill package to the robot."""
485
+ skill_name = params.get("skill_name")
486
+ package = params.get("package") # Base64 encoded skill package
487
+
488
+ if not skill_name:
489
+ raise ValueError("skill_name is required")
490
+
491
+ self._log(f"Deploying skill: {skill_name}")
492
+
493
+ # In real implementation, this would:
494
+ # 1. Decode the package
495
+ # 2. Validate the skill
496
+ # 3. Save to robot's skill directory
497
+ # 4. Register with skill executor
498
+
499
+ return {"success": True, "skillName": skill_name}
500
+
501
+ async def _handle_run_skill(self, params: Dict) -> Dict:
502
+ """Run a skill on the robot."""
503
+ skill_name = params.get("skill_name")
504
+ skill_params = params.get("params", {})
505
+
506
+ if not skill_name:
507
+ raise ValueError("skill_name is required")
508
+
509
+ self._log(f"Running skill: {skill_name} with params: {skill_params}")
510
+
511
+ # In real implementation, this would:
512
+ # 1. Load the skill
513
+ # 2. Execute with parameters
514
+ # 3. Return results
515
+
516
+ # Mock execution
517
+ await asyncio.sleep(1.0) # Simulate skill execution
518
+
519
+ return {
520
+ "success": True,
521
+ "skillName": skill_name,
522
+ "message": f"Skill {skill_name} completed",
523
+ "data": {}
524
+ }
525
+
526
+ # =========================================================================
527
+ # Broadcasting
528
+ # =========================================================================
529
+
530
+ async def _broadcast_servo_state(self):
531
+ """Broadcast current servo state to all connected clients."""
532
+ if not self.robot or not self.connections:
533
+ return
534
+
535
+ servo_states = []
536
+ for sid, state in self.robot.servo_states.items():
537
+ servo_states.append({
538
+ "id": sid,
539
+ "position": state.position,
540
+ "velocity": state.velocity,
541
+ "temperature": state.temperature,
542
+ "load": state.load
543
+ })
544
+
545
+ message = json.dumps({
546
+ "event": "servo_state",
547
+ "data": {"servos": servo_states}
548
+ })
549
+
550
+ await asyncio.gather(
551
+ *[ws.send(message) for ws in self.connections],
552
+ return_exceptions=True
553
+ )
554
+
555
+ async def _broadcast_loop(self):
556
+ """Periodically broadcast servo state to all clients."""
557
+ while self._running:
558
+ try:
559
+ await self._broadcast_servo_state()
560
+ except Exception as e:
561
+ self._log(f"Broadcast error: {e}")
562
+ await asyncio.sleep(0.05) # 20Hz update rate
563
+
564
+ # =========================================================================
565
+ # Server Control
566
+ # =========================================================================
567
+
568
+ async def start(self):
569
+ """Start the WebSocket server."""
570
+ if not HAS_WEBSOCKETS:
571
+ raise RuntimeError("websockets library not installed. Run: pip install websockets")
572
+
573
+ self._running = True
574
+
575
+ async with websockets.serve(
576
+ self.handle_connection,
577
+ "localhost",
578
+ self.port
579
+ ) as server:
580
+ self._log(f"Server started on ws://localhost:{self.port}")
581
+
582
+ # Start broadcast loop
583
+ self._broadcast_task = asyncio.create_task(self._broadcast_loop())
584
+
585
+ # Wait forever
586
+ await asyncio.Future()
587
+
588
+ def stop(self):
589
+ """Stop the server."""
590
+ self._running = False
591
+ if self._broadcast_task:
592
+ self._broadcast_task.cancel()
593
+
594
+
595
+ def run_bridge_server(port: int = 8765, verbose: bool = False):
596
+ """
597
+ Run the ATE bridge server.
598
+
599
+ Args:
600
+ port: WebSocket port to listen on
601
+ verbose: Enable verbose logging
602
+ """
603
+ server = ATEBridgeServer(port=port, verbose=verbose)
604
+
605
+ print(f"\n{'='*50}")
606
+ print(f" ATE Bridge Server")
607
+ print(f"{'='*50}")
608
+ print(f" Port: {port}")
609
+ print(f" URL: ws://localhost:{port}")
610
+ print(f"{'='*50}")
611
+ print("\nWaiting for Artifex to connect...")
612
+ print("Press Ctrl+C to stop\n")
613
+
614
+ try:
615
+ asyncio.run(server.start())
616
+ except KeyboardInterrupt:
617
+ print("\nShutting down...")
618
+ server.stop()
619
+
620
+
621
+ if __name__ == "__main__":
622
+ run_bridge_server(verbose=True)