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.
- luckyrobots/__init__.py +23 -12
- luckyrobots/client.py +800 -0
- luckyrobots/config/robots.yaml +231 -71
- luckyrobots/engine/__init__.py +23 -0
- luckyrobots/{utils → engine}/check_updates.py +108 -48
- luckyrobots/{utils → engine}/download.py +61 -39
- luckyrobots/engine/manager.py +427 -0
- luckyrobots/grpc/__init__.py +6 -0
- luckyrobots/grpc/generated/__init__.py +18 -0
- luckyrobots/grpc/generated/agent_pb2.py +61 -0
- luckyrobots/grpc/generated/agent_pb2_grpc.py +255 -0
- luckyrobots/grpc/generated/camera_pb2.py +45 -0
- luckyrobots/grpc/generated/camera_pb2_grpc.py +155 -0
- luckyrobots/grpc/generated/common_pb2.py +39 -0
- luckyrobots/grpc/generated/common_pb2_grpc.py +27 -0
- luckyrobots/grpc/generated/media_pb2.py +35 -0
- luckyrobots/grpc/generated/media_pb2_grpc.py +27 -0
- luckyrobots/grpc/generated/mujoco_pb2.py +47 -0
- luckyrobots/grpc/generated/mujoco_pb2_grpc.py +248 -0
- luckyrobots/grpc/generated/scene_pb2.py +54 -0
- luckyrobots/grpc/generated/scene_pb2_grpc.py +248 -0
- luckyrobots/grpc/generated/telemetry_pb2.py +43 -0
- luckyrobots/grpc/generated/telemetry_pb2_grpc.py +154 -0
- luckyrobots/grpc/generated/viewport_pb2.py +48 -0
- luckyrobots/grpc/generated/viewport_pb2_grpc.py +155 -0
- luckyrobots/grpc/proto/agent.proto +152 -0
- luckyrobots/grpc/proto/camera.proto +41 -0
- luckyrobots/grpc/proto/common.proto +36 -0
- luckyrobots/grpc/proto/hazel_rpc.proto +32 -0
- luckyrobots/grpc/proto/media.proto +26 -0
- luckyrobots/grpc/proto/mujoco.proto +64 -0
- luckyrobots/grpc/proto/scene.proto +70 -0
- luckyrobots/grpc/proto/telemetry.proto +43 -0
- luckyrobots/grpc/proto/viewport.proto +45 -0
- luckyrobots/luckyrobots.py +212 -0
- luckyrobots/models/__init__.py +13 -0
- luckyrobots/models/camera.py +97 -0
- luckyrobots/models/observation.py +135 -0
- luckyrobots/{utils/helpers.py → utils.py} +75 -40
- luckyrobots-0.1.70.dist-info/METADATA +262 -0
- luckyrobots-0.1.70.dist-info/RECORD +44 -0
- {luckyrobots-0.1.68.dist-info → luckyrobots-0.1.70.dist-info}/WHEEL +1 -1
- luckyrobots/core/luckyrobots.py +0 -628
- luckyrobots/core/manager.py +0 -236
- luckyrobots/core/models.py +0 -68
- luckyrobots/core/node.py +0 -273
- luckyrobots/message/__init__.py +0 -18
- luckyrobots/message/pubsub.py +0 -145
- luckyrobots/message/srv/client.py +0 -81
- luckyrobots/message/srv/service.py +0 -135
- luckyrobots/message/srv/types.py +0 -83
- luckyrobots/message/transporter.py +0 -427
- luckyrobots/utils/event_loop.py +0 -94
- luckyrobots/utils/sim_manager.py +0 -413
- luckyrobots-0.1.68.dist-info/METADATA +0 -253
- luckyrobots-0.1.68.dist-info/RECORD +0 -24
- {luckyrobots-0.1.68.dist-info → luckyrobots-0.1.70.dist-info}/licenses/LICENSE +0 -0
luckyrobots/core/manager.py
DELETED
|
@@ -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
|
-
)
|
luckyrobots/core/models.py
DELETED
|
@@ -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")
|
luckyrobots/message/__init__.py
DELETED
|
@@ -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
|
-
]
|