luckyrobots 0.1.68__py3-none-any.whl → 0.1.70__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 (57) hide show
  1. luckyrobots/__init__.py +23 -12
  2. luckyrobots/client.py +800 -0
  3. luckyrobots/config/robots.yaml +231 -71
  4. luckyrobots/engine/__init__.py +23 -0
  5. luckyrobots/{utils → engine}/check_updates.py +108 -48
  6. luckyrobots/{utils → engine}/download.py +61 -39
  7. luckyrobots/engine/manager.py +427 -0
  8. luckyrobots/grpc/__init__.py +6 -0
  9. luckyrobots/grpc/generated/__init__.py +18 -0
  10. luckyrobots/grpc/generated/agent_pb2.py +61 -0
  11. luckyrobots/grpc/generated/agent_pb2_grpc.py +255 -0
  12. luckyrobots/grpc/generated/camera_pb2.py +45 -0
  13. luckyrobots/grpc/generated/camera_pb2_grpc.py +155 -0
  14. luckyrobots/grpc/generated/common_pb2.py +39 -0
  15. luckyrobots/grpc/generated/common_pb2_grpc.py +27 -0
  16. luckyrobots/grpc/generated/media_pb2.py +35 -0
  17. luckyrobots/grpc/generated/media_pb2_grpc.py +27 -0
  18. luckyrobots/grpc/generated/mujoco_pb2.py +47 -0
  19. luckyrobots/grpc/generated/mujoco_pb2_grpc.py +248 -0
  20. luckyrobots/grpc/generated/scene_pb2.py +54 -0
  21. luckyrobots/grpc/generated/scene_pb2_grpc.py +248 -0
  22. luckyrobots/grpc/generated/telemetry_pb2.py +43 -0
  23. luckyrobots/grpc/generated/telemetry_pb2_grpc.py +154 -0
  24. luckyrobots/grpc/generated/viewport_pb2.py +48 -0
  25. luckyrobots/grpc/generated/viewport_pb2_grpc.py +155 -0
  26. luckyrobots/grpc/proto/agent.proto +152 -0
  27. luckyrobots/grpc/proto/camera.proto +41 -0
  28. luckyrobots/grpc/proto/common.proto +36 -0
  29. luckyrobots/grpc/proto/hazel_rpc.proto +32 -0
  30. luckyrobots/grpc/proto/media.proto +26 -0
  31. luckyrobots/grpc/proto/mujoco.proto +64 -0
  32. luckyrobots/grpc/proto/scene.proto +70 -0
  33. luckyrobots/grpc/proto/telemetry.proto +43 -0
  34. luckyrobots/grpc/proto/viewport.proto +45 -0
  35. luckyrobots/luckyrobots.py +212 -0
  36. luckyrobots/models/__init__.py +13 -0
  37. luckyrobots/models/camera.py +97 -0
  38. luckyrobots/models/observation.py +135 -0
  39. luckyrobots/{utils/helpers.py → utils.py} +75 -40
  40. luckyrobots-0.1.70.dist-info/METADATA +262 -0
  41. luckyrobots-0.1.70.dist-info/RECORD +44 -0
  42. {luckyrobots-0.1.68.dist-info → luckyrobots-0.1.70.dist-info}/WHEEL +1 -1
  43. luckyrobots/core/luckyrobots.py +0 -628
  44. luckyrobots/core/manager.py +0 -236
  45. luckyrobots/core/models.py +0 -68
  46. luckyrobots/core/node.py +0 -273
  47. luckyrobots/message/__init__.py +0 -18
  48. luckyrobots/message/pubsub.py +0 -145
  49. luckyrobots/message/srv/client.py +0 -81
  50. luckyrobots/message/srv/service.py +0 -135
  51. luckyrobots/message/srv/types.py +0 -83
  52. luckyrobots/message/transporter.py +0 -427
  53. luckyrobots/utils/event_loop.py +0 -94
  54. luckyrobots/utils/sim_manager.py +0 -413
  55. luckyrobots-0.1.68.dist-info/METADATA +0 -253
  56. luckyrobots-0.1.68.dist-info/RECORD +0 -24
  57. {luckyrobots-0.1.68.dist-info → luckyrobots-0.1.70.dist-info}/licenses/LICENSE +0 -0
@@ -1,236 +0,0 @@
1
- """
2
- WebSocket server for distributed node communication.
3
-
4
- This module provides a WebSocket server that acts as a central hub for distributed
5
- nodes to discover each other and communicate.
6
- """
7
-
8
- import msgpack
9
- import asyncio
10
- import logging
11
- from typing import Dict, Set
12
-
13
- from fastapi import FastAPI, WebSocket
14
-
15
- from ..message.transporter import MessageType, TransportMessage
16
-
17
- logging.basicConfig(
18
- level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
19
- )
20
- logger = logging.getLogger("manager")
21
-
22
- # FastAPI app
23
- app = FastAPI()
24
-
25
-
26
- class Manager:
27
- def __init__(self):
28
- # Dict of node name to WebSocket connection
29
- self.active_nodes: Dict[str, WebSocket] = {}
30
-
31
- # Dict of topic to set of subscribed node names
32
- self.subscriptions: Dict[str, Set[str]] = {}
33
-
34
- # Dict of service name to node name
35
- self.services: Dict[str, str] = {}
36
-
37
- # Lock for thread safety
38
- self.lock = asyncio.Lock()
39
-
40
- async def register_node(self, node_name: str, websocket: WebSocket):
41
- """Register a node with the manager"""
42
- async with self.lock:
43
- self.active_nodes[node_name] = websocket
44
- logger.info(f"Node registered: {node_name}")
45
-
46
- async def unregister_node(self, node_name: str):
47
- """Unregister a node from the manager"""
48
- async with self.lock:
49
- # Remove the node from active nodes
50
- if node_name in self.active_nodes:
51
- del self.active_nodes[node_name]
52
-
53
- # Remove the node from subscriptions
54
- for topic, nodes in list(self.subscriptions.items()):
55
- if node_name in nodes:
56
- nodes.remove(node_name)
57
- if not nodes:
58
- del self.subscriptions[topic]
59
-
60
- # Remove the node from services
61
- for service, provider in list(self.services.items()):
62
- if provider == node_name:
63
- del self.services[service]
64
-
65
- logger.info(f"Node unregistered: {node_name}")
66
-
67
- async def subscribe(self, node_name: str, topic: str):
68
- """Subscribe a node to a topic"""
69
- async with self.lock:
70
- if topic not in self.subscriptions:
71
- self.subscriptions[topic] = set()
72
- self.subscriptions[topic].add(node_name)
73
- logger.debug(f"Node {node_name} subscribed to topic: {topic}")
74
-
75
- async def unsubscribe(self, node_name: str, topic: str):
76
- """Unsubscribe a node from a topic"""
77
- async with self.lock:
78
- if topic in self.subscriptions and node_name in self.subscriptions[topic]:
79
- self.subscriptions[topic].remove(node_name)
80
- if not self.subscriptions[topic]:
81
- del self.subscriptions[topic]
82
- logger.debug(f"Node {node_name} unsubscribed from topic: {topic}")
83
-
84
- async def register_service(self, node_name: str, service_name: str):
85
- """Register a service with the manager"""
86
- async with self.lock:
87
- self.services[service_name] = node_name
88
- logger.debug(f"Service registered: {service_name} by node {node_name}")
89
-
90
- async def unregister_service(self, node_name: str, service_name: str):
91
- """Unregister a service from the manager"""
92
- async with self.lock:
93
- if (
94
- service_name in self.services
95
- and self.services[service_name] == node_name
96
- ):
97
- del self.services[service_name]
98
- logger.debug(f"Service unregistered: {service_name}")
99
-
100
- async def route_message(self, message: TransportMessage):
101
- """Route a message to the appropriate nodes"""
102
- try:
103
- # Handle message based on its type
104
- if message.msg_type == MessageType.PUBLISH:
105
- await self._route_publish(message)
106
- elif message.msg_type == MessageType.SERVICE_REQUEST:
107
- await self._route_service_request(message)
108
- elif message.msg_type == MessageType.SERVICE_RESPONSE:
109
- await self._route_service_response(message)
110
- else:
111
- # For other message types, just log and ignore
112
- logger.debug(
113
- f"Received message of type {message.msg_type}, not routing"
114
- )
115
- except Exception as e:
116
- logger.error(f"Error routing message: {e}")
117
-
118
- async def _route_publish(self, message: TransportMessage):
119
- """Route a publish message to subscribed nodes"""
120
- topic = message.topic_or_service
121
- sender_node = message.node_name
122
-
123
- # Get the list of subscribed nodes for this topic
124
- async with self.lock:
125
- subscribers = self.subscriptions.get(topic, set()).copy()
126
-
127
- # Send the message to all subscribers except the sender
128
- for node_name in subscribers:
129
- if node_name != sender_node and node_name in self.active_nodes:
130
- try:
131
- await self.active_nodes[node_name].send(
132
- msgpack.dumps(message.dict())
133
- )
134
- except Exception as e:
135
- logger.error(f"Error sending to node {node_name}: {e}")
136
-
137
- async def _route_service_request(self, message: TransportMessage):
138
- """Route a service request to the appropriate node"""
139
- service_name = message.topic_or_service
140
- requester_node = message.node_name
141
-
142
- # Find the service provider
143
- async with self.lock:
144
- provider_node = self.services.get(service_name)
145
-
146
- if not provider_node:
147
- # Service not found, send error response
148
- error_response = TransportMessage(
149
- msg_type=MessageType.SERVICE_RESPONSE,
150
- node_name="node_server",
151
- topic_or_service=service_name,
152
- message_id=message.message_id,
153
- data={"error": f"Service {service_name} not found"},
154
- )
155
-
156
- if requester_node in self.active_nodes:
157
- try:
158
- await self.active_nodes[requester_node].send(
159
- msgpack.dumps(error_response.dict())
160
- )
161
- except Exception as e:
162
- logger.error(
163
- f"Error sending error response to node {requester_node}: {e}"
164
- )
165
- return
166
-
167
- # Forward the request to the service provider
168
- if provider_node in self.active_nodes:
169
- try:
170
- await self.active_nodes[provider_node].send_bytes(
171
- msgpack.dumps(message.dict())
172
- )
173
- except Exception as e:
174
- logger.error(
175
- f"Error forwarding service request to node {provider_node}: {e}"
176
- )
177
- raise e
178
- else:
179
- # Provider not connected, send error response
180
- error_response = TransportMessage(
181
- msg_type=MessageType.SERVICE_RESPONSE,
182
- node_name="node_server",
183
- topic_or_service=service_name,
184
- message_id=message.message_id,
185
- data={"error": f"Service provider {provider_node} is not connected"},
186
- )
187
-
188
- if requester_node in self.active_nodes:
189
- try:
190
- await self.active_nodes[requester_node].send(
191
- msgpack.dumps(error_response.dict())
192
- )
193
- except Exception as e:
194
- logger.error(
195
- f"Error sending error response to node {requester_node}: {e}"
196
- )
197
-
198
- async def _route_service_response(self, message: TransportMessage):
199
- """Route a service response to the appropriate node"""
200
- service_name = message.topic_or_service
201
- message_id = message.message_id
202
-
203
- if not message_id:
204
- logger.error(f"Service response missing message_id: {message}")
205
- return
206
-
207
- # Extract requester node name from message_id (format: node_name_service_name_timestamp_id)
208
- try:
209
- parts = message_id.split("_")
210
- requester_node = parts[0]
211
-
212
- # For namespaced nodes, reconstruct the full name
213
- if len(parts) > 4: # There are more underscores in the node name
214
- requester_node = "_".join(
215
- parts[:-3]
216
- ) # Everything except the last 3 parts
217
- except Exception as e:
218
- logger.error(
219
- f"Error extracting requester node from message_id {message_id}: {e}"
220
- )
221
- return
222
-
223
- # Forward the response to the requester
224
- if requester_node in self.active_nodes:
225
- try:
226
- await self.active_nodes[requester_node].send(
227
- msgpack.dumps(message.dict())
228
- )
229
- except Exception as e:
230
- logger.error(
231
- f"Error forwarding service response to node {requester_node}: {e}"
232
- )
233
- else:
234
- logger.warning(
235
- f"Requester node {requester_node} not connected for response {message_id}"
236
- )
@@ -1,68 +0,0 @@
1
- """
2
- PyDantic models for the LuckyRobots framework.
3
-
4
- This module contains the PyDantic models that are used to define
5
- the data structures being sent over the WebSocket transport.
6
- """
7
-
8
- from typing import Dict, List, Optional, Union
9
- from pydantic import BaseModel, Field
10
- import numpy as np
11
- import cv2
12
-
13
-
14
- class CameraShape(BaseModel):
15
- """Shape of camera images"""
16
-
17
- width: float = Field(description="Width of the image")
18
- height: float = Field(description="Height of the image")
19
- channel: int = Field(description="Number of color channels")
20
-
21
-
22
- class CameraData(BaseModel):
23
- """Camera data in observations"""
24
-
25
- camera_name: str = Field(alias="CameraName", description="Name of the camera")
26
- dtype: str = Field(description="Data type of the image")
27
- shape: Union[CameraShape, Dict[str, Union[float, int]]] = Field(
28
- description="Shape of the image"
29
- )
30
- time_stamp: Optional[str] = Field(
31
- None, alias="TimeStamp", description="Camera timestamp"
32
- )
33
- image_data: Optional[bytes] = Field(
34
- None, alias="ImageData", description="Image data"
35
- )
36
-
37
- def process_image(self) -> None:
38
- """Process the base64 image data into a numpy array"""
39
- if self.image_data is None:
40
- return None
41
-
42
- nparr = np.frombuffer(self.image_data, np.uint8)
43
- self.image_data = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
44
-
45
- class Config:
46
- populate_by_name = True
47
-
48
-
49
- class ObservationModel(BaseModel):
50
- """Observation model that matches the JSON structure"""
51
-
52
- observation_state: Dict[str, float] = Field(
53
- alias="ObservationState", description="State values for actuators"
54
- )
55
- observation_cameras: Optional[List[CameraData]] = Field(
56
- default=None, alias="ObservationCameras", description="List of camera data"
57
- )
58
-
59
- def process_all_cameras(self) -> None:
60
- """Process all camera images in the observation"""
61
- if self.observation_cameras is None:
62
- return
63
-
64
- for camera in self.observation_cameras:
65
- camera.process_image()
66
-
67
- class Config:
68
- populate_by_name = True
luckyrobots/core/node.py DELETED
@@ -1,273 +0,0 @@
1
- """
2
- Node class for the LuckyRobots framework.
3
-
4
- This module contains the Node class that is used to create a node in the
5
- LuckyRobots framework. A node is a component that can publish messages,
6
- subscribe to messages, and call services.
7
- """
8
-
9
- import asyncio
10
- import logging
11
- import threading
12
- import uuid
13
- from typing import Any, Callable, Dict, Type
14
-
15
- from ..message.pubsub import Publisher, Subscriber
16
- from ..message.srv.client import ServiceClient
17
- from ..message.srv.service import ServiceServer, ServiceError
18
- from ..message.transporter import Transporter
19
- from ..utils.event_loop import run_coroutine
20
-
21
- logging.basicConfig(
22
- level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
23
- )
24
- logger = logging.getLogger("node")
25
-
26
-
27
- class Node:
28
- def __init__(
29
- self, name: str, namespace: str = "", host: str = None, port: int = None
30
- ):
31
- self.name = name
32
- self.namespace = namespace.strip("/")
33
- self.full_name = (
34
- f"/{self.namespace}/{self.name}" if self.namespace else f"/{self.name}"
35
- )
36
-
37
- # Get host and port from parameters if not provided
38
- self.host = host
39
- self.port = port
40
-
41
- # Create a unique ID for this node instance
42
- self.instance_id = str(uuid.uuid4())
43
-
44
- self._publishers: Dict[str, Publisher] = {}
45
- self._subscribers: Dict[str, Subscriber] = {}
46
- self._services: Dict[str, ServiceServer] = {}
47
- self._clients: Dict[str, ServiceClient] = {}
48
- self._running = False
49
- self._shutdown_event = threading.Event()
50
-
51
- # Initialize WebSocket transporter
52
- # Note: We connect to the same server for all communications
53
- self.transporter = Transporter(
54
- node_name=self.full_name,
55
- uuid=self.instance_id,
56
- host=self.host,
57
- port=self.port,
58
- )
59
-
60
- logger.info(f"Created node: {self.full_name} (ID: {self.instance_id})")
61
-
62
- def get_qualified_name(self, name: str) -> str:
63
- """Get the qualified name for a given name"""
64
- if name.startswith("/"):
65
- return name
66
-
67
- return f"{self.full_name}/{name}"
68
-
69
- def create_publisher(
70
- self, message_type: Type, topic: str, queue_size: int = 10
71
- ) -> Publisher:
72
- """Create a publisher for a given topic"""
73
- qualified_topic = self.get_qualified_name(topic)
74
- publisher = Publisher(qualified_topic, message_type, queue_size)
75
- self._publishers[qualified_topic] = publisher
76
-
77
- # Wrap the publish method to distribute messages
78
- original_publish = publisher.publish
79
-
80
- def distributed_publish(message):
81
- # Publish locally
82
- original_publish(message)
83
-
84
- # Publish to remote nodes via transport layer
85
- self.transporter.publish(qualified_topic, message)
86
-
87
- publisher.publish = distributed_publish
88
-
89
- return publisher
90
-
91
- def create_subscription(
92
- self,
93
- message_type: Type,
94
- topic: str,
95
- callback: Callable[[Any], None],
96
- queue_size: int = 10,
97
- ) -> Subscriber:
98
- """Create a subscriber for a given topic"""
99
- qualified_topic = self.get_qualified_name(topic)
100
- subscriber = Subscriber(qualified_topic, message_type, callback, queue_size)
101
- self._subscribers[qualified_topic] = subscriber
102
-
103
- # Create a wrapper for the callback that handles message type conversion
104
- def transport_callback(data):
105
- # Try to convert the data to the expected message type
106
- if hasattr(message_type, "parse_obj"):
107
- try:
108
- message = message_type.parse_obj(data)
109
- callback(message)
110
- except Exception as e:
111
- logger.error(f"Error converting message data: {e}")
112
- else:
113
- # If the message type doesn't have parse_obj, pass the data directly
114
- callback(data)
115
-
116
- # Subscribe to the topic on the transport layer
117
- self.transporter.subscribe(qualified_topic, transport_callback)
118
-
119
- return subscriber
120
-
121
- def create_client(self, service_type: Type, service_name: str) -> ServiceClient:
122
- """Create a client for a given service"""
123
- qualified_name = self.get_qualified_name(service_name)
124
- client = ServiceClient(service_type, qualified_name)
125
- self._clients[qualified_name] = client
126
-
127
- # Store the original call method
128
- original_call = client.call
129
-
130
- # Create a new call method that tries both local and remote services
131
- async def distributed_call(request, timeout=30.0):
132
- try:
133
- # Try to call the service locally first
134
- return await original_call(request, qualified_name, timeout=timeout)
135
- except Exception as e:
136
- logger.debug(f"Local service call failed: {e}, trying remote service")
137
-
138
- # Convert the request to a dictionary
139
- if hasattr(request, "dict"):
140
- request_data = request.dict()
141
- elif hasattr(request, "to_dict"):
142
- request_data = request.to_dict()
143
- else:
144
- request_data = request
145
-
146
- # Call the service through the transport
147
- try:
148
- response_data = await self.transporter.call_service(
149
- qualified_name, request_data, timeout=timeout
150
- )
151
-
152
- # Check if response has an error
153
- if isinstance(response_data, dict) and "error" in response_data:
154
- raise ServiceError(
155
- response_data.get("error", "Unknown service error")
156
- )
157
-
158
- # Convert the response to the expected response type
159
- response_type = getattr(service_type, "Response", None)
160
-
161
- if response_type and hasattr(response_type, "parse_obj"):
162
- try:
163
- return response_type.parse_obj(response_data)
164
- except Exception as parse_error:
165
- logger.error(
166
- f"Error converting response data: {parse_error}"
167
- )
168
- raise ServiceError(f"Error parsing response: {parse_error}")
169
- else:
170
- # If no specific response type or parsing failed, return the data directly
171
- return response_data
172
- except Exception as remote_error:
173
- logger.error(f"Remote service call failed: {remote_error}")
174
- raise ServiceError(f"Remote service call failed: {remote_error}")
175
-
176
- # Replace the call method
177
- client.call = distributed_call
178
-
179
- return client
180
-
181
- async def create_service(
182
- self, service_type: Type, service_name: str, handler: Callable[[Any], Any]
183
- ) -> ServiceServer:
184
- """Create a service for a given service name"""
185
- qualified_name = self.get_qualified_name(service_name)
186
- service = ServiceServer(service_type, qualified_name, handler)
187
- self._services[qualified_name] = service
188
-
189
- # Create a wrapper for the handler that handles message type conversion and async
190
- async def transport_handler(request_data):
191
- # Try to convert the request data to the expected request type
192
- request_type = getattr(service_type, "Request", service_type)
193
-
194
- if hasattr(request_type, "parse_obj"):
195
- try:
196
- request = request_type.parse_obj(request_data)
197
- except Exception as e:
198
- logger.error(f"Error converting request data: {e}")
199
- return {"error": str(e), "success": False}
200
- else:
201
- # If the request type doesn't have parse_obj, pass the data directly
202
- request = request_data
203
-
204
- # Call the original handler and properly handle async
205
- try:
206
- if asyncio.iscoroutinefunction(handler):
207
- # Await the coroutine directly
208
- response = await handler(request)
209
- else:
210
- # Run synchronous handler
211
- response = handler(request)
212
-
213
- # If the response is also a coroutine, await it (sometimes happens with wrapped handlers)
214
- if asyncio.iscoroutine(response):
215
- response = await response
216
-
217
- # Convert the response to a dictionary
218
- if hasattr(response, "dict"):
219
- return response.dict()
220
- elif isinstance(response, dict):
221
- return response
222
- else:
223
- # Try to convert to dict
224
- try:
225
- return dict(response)
226
- except (TypeError, ValueError):
227
- return {"value": response, "success": True}
228
- except Exception as e:
229
- logger.error(f"Error in service handler: {e}")
230
- return {"error": str(e), "success": False}
231
-
232
- # Register with transport using the async-aware wrapper
233
- self.transporter.register_service(qualified_name, transport_handler)
234
-
235
- return service
236
-
237
- def create_service_client(
238
- self,
239
- service_type: Type,
240
- service_name: str,
241
- host: str = "localhost",
242
- port: int = 3000,
243
- ) -> ServiceClient:
244
- """Create a service client for a given service name"""
245
- qualified_name = self.get_qualified_name(service_name)
246
- client = ServiceClient(service_type, qualified_name, host, port)
247
- self._clients[qualified_name] = client
248
- return client
249
-
250
- def start(self) -> None:
251
- """Start the node"""
252
- self._running = True
253
- run_coroutine(self._setup_async())
254
- logger.info(f"Node {self.full_name} started")
255
-
256
- async def _setup_async(self):
257
- pass
258
-
259
- def spin(self) -> None:
260
- """Spin the node"""
261
- logger.info(f"Node {self.full_name} spinning")
262
- self._shutdown_event.wait()
263
- logger.info(f"Node {self.full_name} stopped spinning")
264
-
265
- def shutdown(self) -> None:
266
- """Shutdown the node"""
267
- self._running = False
268
-
269
- # Shutdown WebSocket transporter
270
- self.transporter.shutdown()
271
-
272
- self._shutdown_event.set()
273
- logger.info(f"Node {self.full_name} shutdown")
@@ -1,18 +0,0 @@
1
- """Service and publisher/subscriber patterns for LuckyRobots.
2
-
3
- This module provides service and publisher/subscriber patterns for:
4
- - Services (request/response)
5
- - Publishers (one-to-many)
6
- - Subscribers (many-to-one)
7
- """
8
-
9
- from .pubsub import Publisher, Subscriber
10
- from .srv.client import ServiceClient
11
- from .srv.service import ServiceServer
12
-
13
- __all__ = [
14
- "ServiceClient",
15
- "ServiceServer",
16
- "Publisher",
17
- "Subscriber",
18
- ]