osiris-agent 0.1.2__tar.gz → 0.1.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: osiris_agent
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: OSIRIS agent for ROS2/Humble
5
5
  Home-page: https://github.com/nicolaselielll/osiris_agent
6
6
  Author: Nicolas Tuomaala
@@ -17,6 +17,7 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: websockets
19
19
  Requires-Dist: psutil
20
+ Requires-Dist: pyzmq
20
21
  Provides-Extra: ros
21
22
  Requires-Dist: rclpy; extra == "ros"
22
23
  Dynamic: author
@@ -14,6 +14,8 @@ from rosidl_runtime_py.utilities import get_message
14
14
  from rosidl_runtime_py import message_to_ordereddict
15
15
  import psutil
16
16
 
17
+ from .bt_collector import BTCollector
18
+
17
19
  # Security and configuration constants
18
20
  MAX_SUBSCRIPTIONS = 100
19
21
  ALLOWED_TOPIC_PREFIXES = ['/', ]
@@ -30,8 +32,9 @@ class WebBridge(Node):
30
32
  auth_token = os.environ.get('OSIRIS_AUTH_TOKEN')
31
33
  if not auth_token:
32
34
  raise ValueError("OSIRIS_AUTH_TOKEN environment variable must be set")
33
-
35
+
34
36
  self.ws_url = f'wss://osiris-gateway.fly.dev?robot=true&token={auth_token}'
37
+ # self.ws_url = f'ws://host.docker.internal:8080?robot=true&token={auth_token}'
35
38
  self.ws = None
36
39
  self._topic_subs = {}
37
40
  self._topic_subs_lock = threading.Lock()
@@ -57,6 +60,7 @@ class WebBridge(Node):
57
60
  self._last_sent_topics = None
58
61
  self._last_sent_actions = None
59
62
  self._last_sent_services = None
63
+ self._cached_bt_tree_event = None # Cache tree event until WS connects
60
64
 
61
65
  self._check_graph_changes()
62
66
  self.create_timer(0.1, self._check_graph_changes)
@@ -64,6 +68,19 @@ class WebBridge(Node):
64
68
  self.create_timer(1.0, self._collect_telemetry)
65
69
 
66
70
  threading.Thread(target=self._run_ws_client, daemon=True).start()
71
+
72
+ # Initialize BT Collector for Groot2 events (optional)
73
+ bt_enabled = os.environ.get('OSIRIS_BT_COLLECTOR_ENABLED', '').lower() in ('true', '1', 'yes')
74
+ if bt_enabled:
75
+ self._bt_collector = BTCollector(
76
+ event_callback=self._on_bt_event,
77
+ logger=self.get_logger()
78
+ )
79
+ self._bt_collector.start()
80
+ else:
81
+ self._bt_collector = None
82
+
83
+ self.get_logger().info("🚀 Osiris agent running")
67
84
 
68
85
  # Create event loop and queue, run websocket client
69
86
  def _run_ws_client(self):
@@ -79,12 +96,10 @@ class WebBridge(Node):
79
96
 
80
97
  while True:
81
98
  try:
82
- self.get_logger().info("Attempting to connect to gateway...")
83
99
  await self._client_loop()
84
100
  except Exception as e:
85
- self.get_logger().error(f"Connection failed: {e}")
101
+ pass # Silent retry
86
102
 
87
- self.get_logger().info(f"Reconnecting in {reconnect_delay} seconds...")
88
103
  await asyncio.sleep(reconnect_delay)
89
104
 
90
105
  reconnect_delay = min(reconnect_delay * 2, RECONNECT_MAX_DELAY)
@@ -96,12 +111,10 @@ class WebBridge(Node):
96
111
  send_task = None
97
112
  try:
98
113
  async with websockets.connect(self.ws_url) as ws:
99
- self.get_logger().info("Connected to gateway (socket opened)")
100
114
  # Wait for gateway auth response before sending initial state
101
115
  try:
102
116
  auth_msg = await ws.recv()
103
117
  except Exception as e:
104
- self.get_logger().error(f"Failed to receive auth message: {e}")
105
118
  return
106
119
 
107
120
  try:
@@ -109,14 +122,9 @@ class WebBridge(Node):
109
122
  except Exception:
110
123
  auth_data = None
111
124
 
112
- self.get_logger().debug(f"Gateway auth message received: {auth_msg}")
113
-
114
125
  if not auth_data or auth_data.get('type') != 'auth_success':
115
- self.get_logger().error(f"Gateway did not authenticate: parsed={auth_data}")
116
126
  return
117
127
 
118
- self.get_logger().info("Authenticated with gateway")
119
-
120
128
  self.ws = ws
121
129
 
122
130
  send_task = asyncio.create_task(self._send_loop(ws))
@@ -125,7 +133,6 @@ class WebBridge(Node):
125
133
 
126
134
  await self._receive_loop(ws)
127
135
  except Exception as e:
128
- self.get_logger().error(f"Error in client loop: {e}")
129
136
  raise
130
137
  finally:
131
138
  if send_task and not send_task.done():
@@ -166,6 +173,12 @@ class WebBridge(Node):
166
173
  await self._send_queue.put(json.dumps(message))
167
174
  self.get_logger().info(f"Sent initial state: {len(nodes)} nodes, {len(topics)} topics, {len(actions)} actions, {len(services)} services")
168
175
 
176
+ # Send cached BT tree event if we have one
177
+ if self._cached_bt_tree_event:
178
+ self.get_logger().info("Sending cached BT tree event")
179
+ await self._send_queue.put(json.dumps(self._cached_bt_tree_event))
180
+ self._cached_bt_tree_event = None
181
+
169
182
  await self._send_bridge_subscriptions()
170
183
 
171
184
  # Send list of currently subscribed topics to gateway
@@ -954,6 +967,33 @@ class WebBridge(Node):
954
967
  }
955
968
  }
956
969
 
970
+ # Handle BT events from BTCollector
971
+ def _on_bt_event(self, event):
972
+ """Handle behavior tree events from BTCollector and forward to websocket."""
973
+ event_type = event.get('type')
974
+
975
+ # Cache tree events if WS not connected yet
976
+ if not self.ws or not self.loop:
977
+ if event_type == 'bt_tree':
978
+ self._cached_bt_tree_event = event
979
+ return
980
+
981
+ try:
982
+ asyncio.run_coroutine_threadsafe(
983
+ self._send_queue.put(json.dumps(event)),
984
+ self.loop
985
+ )
986
+ except Exception as e:
987
+ self.get_logger().error(f"Failed to queue BT event: {e}")
988
+
989
+ def destroy_node(self):
990
+ """Clean up resources before destroying the node."""
991
+ # Stop BT collector
992
+ if self._bt_collector:
993
+ self._bt_collector.stop()
994
+
995
+ super().destroy_node()
996
+
957
997
 
958
998
  # Initialize ROS, create node, and run until shutdown
959
999
  def main(args=None):
@@ -0,0 +1,564 @@
1
+ """
2
+ BT.CPP Groot2 ZMQ Event Collector
3
+
4
+ Connects to the Groot2 ZMQ publisher from BT.CPP and collects:
5
+ - Tree structure (on connect and on change)
6
+ - Node status updates
7
+ - Breakpoint events
8
+
9
+ Events are forwarded to the WebBridge for transmission over WebSocket.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import struct
15
+ import threading
16
+ import time
17
+ from typing import Callable, Optional, Dict, Any, List
18
+ from dataclasses import dataclass, field
19
+ from enum import IntEnum
20
+
21
+ import zmq
22
+ import random # added to support unique_id generation in requests
23
+
24
+ # Default Groot2 configuration (industry standard)
25
+ DEFAULT_SERVER_PORT = 1667 # REQ/REP for tree structure and status polling
26
+ DEFAULT_PUBLISHER_PORT = 1668 # PUB/SUB for breakpoint notifications only
27
+ DEFAULT_HOST = "127.0.0.1"
28
+
29
+ # ZMQ timeouts
30
+ ZMQ_RECV_TIMEOUT_MS = 2000
31
+ ZMQ_RECONNECT_INTERVAL = 2.0
32
+ STATUS_POLL_INTERVAL = 0.1 # Poll for status every 100ms
33
+
34
+
35
+ class NodeStatus(IntEnum):
36
+ """BT.CPP NodeStatus enum values (includes extended states)"""
37
+ IDLE = 0
38
+ RUNNING = 1
39
+ SUCCESS = 2
40
+ FAILURE = 3
41
+ SKIPPED = 4
42
+ # Extended states (BT.CPP internal)
43
+ IDLE_WAS_RUNNING = 11
44
+ IDLE_WAS_SUCCESS = 12
45
+ IDLE_WAS_FAILURE = 13
46
+
47
+ @classmethod
48
+ def to_string(cls, value: int) -> str:
49
+ try:
50
+ return cls(value).name
51
+ except ValueError:
52
+ return f"UNKNOWN({value})"
53
+
54
+
55
+ @dataclass
56
+ class BTNode:
57
+ """Represents a node in the behavior tree"""
58
+ uid: int
59
+ name: str
60
+ tag: str # Node type (e.g., "Sequence", "Action", "Condition")
61
+ status: str = "IDLE"
62
+
63
+
64
+ @dataclass
65
+ class BTTree:
66
+ """Represents the full behavior tree structure"""
67
+ tree_id: Optional[str] = None
68
+ nodes: Dict[int, BTNode] = field(default_factory=dict)
69
+ xml: str = ""
70
+ structure: Dict[str, Any] = field(default_factory=dict)
71
+
72
+
73
+ class BTCollector:
74
+ """
75
+ Collects BT events from Groot2 ZMQ publisher and forwards them.
76
+
77
+ Usage:
78
+ collector = BTCollector(event_callback)
79
+ collector.start()
80
+ # ... later ...
81
+ collector.stop()
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ event_callback: Callable[[Dict[str, Any]], None],
87
+ host: Optional[str] = None,
88
+ server_port: Optional[int] = None,
89
+ publisher_port: Optional[int] = None,
90
+ logger=None
91
+ ):
92
+ """
93
+ Initialize the BT collector.
94
+
95
+ Args:
96
+ event_callback: Function to call with parsed events (dict)
97
+ host: Groot2 host address (default: from BT_HOST env or 127.0.0.1)
98
+ server_port: Groot2 server port (default: from BT_SERVER_PORT env or 1667)
99
+ publisher_port: Groot2 publisher port (default: from BT_PUBLISHER_PORT env or 1668)
100
+ logger: Optional logger (uses print if None)
101
+ """
102
+ self._event_callback = event_callback
103
+ # Read from environment variables with fallback to defaults
104
+ self._host = host or os.environ.get('OSIRIS_BT_HOST', DEFAULT_HOST)
105
+ self._server_port = server_port or int(os.environ.get('OSIRIS_BT_SERVER_PORT', str(DEFAULT_SERVER_PORT)))
106
+ self._publisher_port = publisher_port or int(os.environ.get('OSIRIS_BT_PUBLISHER_PORT', str(DEFAULT_PUBLISHER_PORT)))
107
+ self._logger = logger
108
+
109
+ self._running = False
110
+ self._thread: Optional[threading.Thread] = None
111
+ self._context: Optional[zmq.Context] = None
112
+
113
+ self._current_tree: Optional[BTTree] = None
114
+ self._last_statuses: Dict[int, str] = {}
115
+
116
+ def _log_info(self, msg: str):
117
+ pass # Logging disabled
118
+
119
+ def _log_debug(self, msg: str):
120
+ pass # Logging disabled
121
+
122
+ def _log_error(self, msg: str):
123
+ if self._logger:
124
+ self._logger.error(msg)
125
+ else:
126
+ print(f"[BTCollector ERROR] {msg}")
127
+
128
+ def _log_warn(self, msg: str):
129
+ pass # Logging disabled
130
+
131
+ def start(self):
132
+ """Start the collector in a background thread."""
133
+ if self._running:
134
+ self._log_warn("BTCollector already running")
135
+ return
136
+
137
+ self._running = True
138
+ self._thread = threading.Thread(target=self._run_loop, daemon=True)
139
+ self._thread.start()
140
+ self._log_info(f"BTCollector started (server={self._host}:{self._server_port}, pub={self._publisher_port})")
141
+
142
+ def stop(self):
143
+ """Stop the collector."""
144
+ self._running = False
145
+ if self._thread:
146
+ self._thread.join(timeout=2.0)
147
+ self._thread = None
148
+ self._log_info("BTCollector stopped")
149
+
150
+ def _run_loop(self):
151
+ """Main collector loop - runs in background thread."""
152
+ self._context = zmq.Context()
153
+
154
+ while self._running:
155
+ try:
156
+ self._collect_loop()
157
+ except Exception as e:
158
+ self._log_error(f"Collection error: {e}")
159
+
160
+ if self._running:
161
+ self._log_info(f"Reconnecting in {ZMQ_RECONNECT_INTERVAL}s...")
162
+ time.sleep(ZMQ_RECONNECT_INTERVAL)
163
+
164
+ if self._context:
165
+ self._context.term()
166
+ self._context = None
167
+
168
+ def _collect_loop(self):
169
+ """Single collection session - connects, gets tree, polls for status updates."""
170
+ # Create REQ socket for tree structure AND status polling
171
+ req_socket = self._context.socket(zmq.REQ)
172
+ req_socket.setsockopt(zmq.RCVTIMEO, ZMQ_RECV_TIMEOUT_MS)
173
+ req_socket.setsockopt(zmq.LINGER, 0)
174
+
175
+ try:
176
+ # Connect to server for tree structure and status polling
177
+ server_addr = f"tcp://{self._host}:{self._server_port}"
178
+ self._log_info(f"Connecting to Groot2 server: {server_addr}")
179
+ req_socket.connect(server_addr)
180
+
181
+ # Main loop - request tree first, then poll for status
182
+ self._log_info("Starting BT collection loop...")
183
+ tree_received = False
184
+
185
+ while self._running:
186
+ try:
187
+ # Request tree structure if we don't have it yet
188
+ if not tree_received:
189
+ tree_received = self._request_tree_structure(req_socket)
190
+ if not tree_received:
191
+ # No tree yet, wait and retry
192
+ time.sleep(1.0)
193
+ continue
194
+ # Immediately try to fetch current statuses so the initial tree event can include them
195
+ try:
196
+ self._poll_status(req_socket)
197
+ except Exception:
198
+ # ignore timeout/errors here; we'll keep trying in the main loop
199
+ pass
200
+ # emit bt_tree now with status merged into nodes (if available)
201
+ if self._current_tree:
202
+ self._log_info("Sending initial tree event with statuses")
203
+ self._emit_current_tree_event()
204
+
205
+ # Poll for status updates
206
+ self._poll_status(req_socket)
207
+ time.sleep(STATUS_POLL_INTERVAL)
208
+
209
+ except zmq.Again:
210
+ # Timeout - need to recreate socket because REQ is in bad state
211
+ self._log_warn("Timeout - recreating socket...")
212
+ req_socket.close()
213
+ req_socket = self._context.socket(zmq.REQ)
214
+ req_socket.setsockopt(zmq.RCVTIMEO, ZMQ_RECV_TIMEOUT_MS)
215
+ req_socket.setsockopt(zmq.LINGER, 0)
216
+ req_socket.connect(server_addr)
217
+ tree_received = False # Need to re-request tree after reconnect
218
+ time.sleep(1.0)
219
+
220
+ except zmq.ZMQError as e:
221
+ if "current state" in str(e):
222
+ # Socket in bad state, recreate it
223
+ self._log_warn(f"Socket in bad state, recreating: {e}")
224
+ req_socket.close()
225
+ req_socket = self._context.socket(zmq.REQ)
226
+ req_socket.setsockopt(zmq.RCVTIMEO, ZMQ_RECV_TIMEOUT_MS)
227
+ req_socket.setsockopt(zmq.LINGER, 0)
228
+ req_socket.connect(server_addr)
229
+ tree_received = False
230
+ time.sleep(1.0)
231
+ else:
232
+ self._log_error(f"ZMQ error: {e}")
233
+ time.sleep(0.5)
234
+
235
+ except Exception as e:
236
+ self._log_error(f"Error in collection loop: {e}")
237
+ time.sleep(0.5)
238
+
239
+ finally:
240
+ req_socket.close()
241
+
242
+ def _poll_status(self, socket: zmq.Socket):
243
+ """Poll for status update via REQ/REP."""
244
+ # Send STATUS request: protocol=2, type='S'
245
+ protocol = 2
246
+ req_type = ord('S') # 'S' = STATUS request
247
+ unique_id = random.getrandbits(32)
248
+ header = struct.pack('<BBI', protocol, req_type, unique_id)
249
+
250
+ socket.send(header, zmq.SNDMORE)
251
+ socket.send(b"")
252
+
253
+ parts = socket.recv_multipart()
254
+ if parts and len(parts) >= 2:
255
+ resp_tree_id = self._extract_tree_id_from_header(parts[0])
256
+ if resp_tree_id and (not self._current_tree or self._current_tree.tree_id != resp_tree_id):
257
+ self._log_warn(
258
+ f"Tree changed behind endpoint: {self._current_tree.tree_id if self._current_tree else None} -> {resp_tree_id}. Refreshing FULLTREE..."
259
+ )
260
+ if self._request_tree_structure(socket):
261
+ self._emit_current_tree_event()
262
+ self._handle_status_message(parts, resp_tree_id)
263
+
264
+ def _extract_tree_id_from_header(self, hdr: bytes) -> Optional[str]:
265
+ """
266
+ Groot2 multipart header (22 bytes):
267
+ protocol(1), type(1), unique_id(4), tree_id(16)
268
+ Returns tree_id as hex string or None.
269
+ """
270
+ if not hdr or len(hdr) < 22:
271
+ return None
272
+ return hdr[6:22].hex()
273
+
274
+ def _emit_current_tree_event(self):
275
+ if not self._current_tree:
276
+ return
277
+ nodes_with_status = []
278
+ for nd in getattr(self._current_tree, "nodes_list", []):
279
+ uid = nd.get("uid")
280
+ nd_copy = dict(nd)
281
+ nd_copy["status"] = self._last_statuses.get(uid, NodeStatus.IDLE.name)
282
+ nodes_with_status.append(nd_copy)
283
+ event = {
284
+ "type": "bt_tree",
285
+ "timestamp": time.time(),
286
+ "tree_id": self._current_tree.tree_id,
287
+ "tree": self._current_tree.structure,
288
+ "nodes": nodes_with_status,
289
+ }
290
+ self._log_info(f"Sending tree event ({len(nodes_with_status)} nodes)")
291
+ self._event_callback(event)
292
+
293
+ def _request_tree_structure(self, socket: zmq.Socket) -> bool:
294
+ """Request and parse tree structure from Groot2 server. Returns True if successful."""
295
+ # Groot2 protocol: multipart message with binary header
296
+ # Header: protocol(uint8)=2, type(uint8)='T', unique_id(uint32)
297
+ protocol = 2
298
+ req_type = ord('T') # 'T' = FULLTREE request
299
+ unique_id = random.getrandbits(32)
300
+ header = struct.pack('<BBI', protocol, req_type, unique_id)
301
+
302
+ self._log_info(f"Requesting tree structure...")
303
+ socket.send(header, zmq.SNDMORE)
304
+ socket.send(b"") # Empty body
305
+
306
+ # Receive multipart response
307
+ parts = socket.recv_multipart()
308
+ self._log_info(f"Tree response: {len(parts)} parts")
309
+
310
+ if parts and len(parts) >= 2:
311
+ return self._parse_tree_response(parts)
312
+ else:
313
+ self._log_warn("Invalid tree response (expected 2+ parts)")
314
+ return False
315
+
316
+ def _parse_tree_response(self, parts: list) -> bool:
317
+ """
318
+ Parse tree structure response from Groot2.
319
+ Returns True if successful.
320
+
321
+ Response format (multipart):
322
+ - Part 0: Header (22 bytes): protocol(1), type(1), unique_id(4), tree_id(16)
323
+ - Part 1: XML string of the tree
324
+ """
325
+ if not parts:
326
+ self._log_warn("Empty tree response")
327
+ return False
328
+
329
+ self._log_info(f"Parsing tree response: {len(parts)} parts")
330
+
331
+ try:
332
+ # Part 0: Header
333
+ hdr = parts[0]
334
+ tree_id = None
335
+ if len(hdr) >= 22:
336
+ resp_protocol, resp_type, resp_id = struct.unpack('<BBI', hdr[:6])
337
+ tree_id = hdr[6:22].hex()
338
+ self._log_info(f"Tree header: protocol={resp_protocol}, type={chr(resp_type)}, tree_id={tree_id[:16]}...")
339
+ elif len(hdr) >= 6:
340
+ # Check if error response
341
+ try:
342
+ err = hdr.decode('utf-8')
343
+ self._log_error(f"Error response: {err}")
344
+ if len(parts) > 1:
345
+ self._log_error(f"Detail: {parts[1].decode('utf-8')}")
346
+ return False
347
+ except:
348
+ self._log_warn(f"Short header: {len(hdr)} bytes")
349
+
350
+ # Part 1: XML
351
+ if len(parts) < 2:
352
+ self._log_warn("No XML part in response")
353
+ return False
354
+
355
+ xml = parts[1].decode('utf-8', errors='replace')
356
+ self._log_info(f"Received tree XML: {len(xml)} chars")
357
+
358
+ # Parse XML to extract nodes
359
+ tree = BTTree()
360
+ tree.tree_id = tree_id # Set the tree_id from header
361
+ tree.xml = xml
362
+ nodes_list = self._parse_xml_tree(xml, tree)
363
+
364
+ if not nodes_list:
365
+ self._log_warn("No nodes extracted from tree XML")
366
+ return False
367
+
368
+ # store parsed tree and node list; do not emit tree event here
369
+ tree.nodes_list = nodes_list
370
+ self._current_tree = tree
371
+ self._last_statuses.clear()
372
+ self._log_info(f"Tree structure received: {len(nodes_list)} nodes")
373
+ return True
374
+
375
+ except Exception as e:
376
+ import traceback
377
+ self._log_error(f"Error parsing tree response: {e}\n{traceback.format_exc()}")
378
+ return False
379
+
380
+ def _parse_xml_tree(self, xml: str, tree: BTTree) -> list:
381
+ """Parse XML tree and extract nodes with UIDs."""
382
+ import xml.etree.ElementTree as ET
383
+
384
+ nodes_list = []
385
+ try:
386
+ root = ET.fromstring(xml)
387
+ self._extract_nodes_from_xml(root, tree, nodes_list)
388
+
389
+ # Also build hierarchical tree structure
390
+ tree.structure = self._build_tree_structure(root)
391
+
392
+ self._log_info(f"Extracted {len(nodes_list)} nodes from XML")
393
+ except ET.ParseError as e:
394
+ self._log_error(f"XML parse error: {e}")
395
+
396
+ return nodes_list
397
+
398
+ def _build_tree_structure(self, elem) -> dict:
399
+ """Recursively build hierarchical tree structure from XML."""
400
+ # Find the BehaviorTree element
401
+ behavior_tree = elem.find('.//BehaviorTree')
402
+ if behavior_tree is None:
403
+ return {}
404
+
405
+ # Build from the first child of BehaviorTree (the root node)
406
+ children = list(behavior_tree)
407
+ if not children:
408
+ return {}
409
+
410
+ return self._element_to_tree_node(children[0])
411
+
412
+ def _element_to_tree_node(self, elem) -> dict:
413
+ """Convert an XML element to a tree node dict with children."""
414
+ node = {
415
+ 'tag': elem.tag,
416
+ 'name': elem.attrib.get('name', elem.attrib.get('ID', elem.tag)),
417
+ 'attributes': dict(elem.attrib),
418
+ }
419
+
420
+ # Add uid if present
421
+ uid_str = elem.attrib.get('_uid')
422
+ if uid_str:
423
+ try:
424
+ node['uid'] = int(uid_str)
425
+ except ValueError:
426
+ pass
427
+
428
+ # Recursively add children
429
+ children = []
430
+ for child in elem:
431
+ children.append(self._element_to_tree_node(child))
432
+
433
+ if children:
434
+ node['children'] = children
435
+
436
+ return node
437
+
438
+ def _extract_nodes_from_xml(self, elem, tree: BTTree, nodes_list: list):
439
+ """Recursively extract nodes from XML element."""
440
+ # Check if this element has a _uid attribute
441
+ uid_str = elem.attrib.get('_uid')
442
+ if uid_str:
443
+ try:
444
+ uid = int(uid_str)
445
+ name = elem.attrib.get('name', elem.attrib.get('ID', elem.tag))
446
+ tag = elem.tag
447
+
448
+ node = BTNode(uid=uid, name=name, tag=tag)
449
+ tree.nodes[uid] = node
450
+ nodes_list.append({
451
+ 'uid': uid,
452
+ 'name': name,
453
+ 'tag': tag
454
+ })
455
+ self._log_info(f" Parsed node {uid}: {name} ({tag})")
456
+ except ValueError:
457
+ pass
458
+
459
+ # Recurse into children
460
+ for child in elem:
461
+ self._extract_nodes_from_xml(child, tree, nodes_list)
462
+
463
+ def _handle_status_message(self, parts: list, tree_id: Optional[str] = None):
464
+ """
465
+ Parse status update message from Groot2 REQ/REP response.
466
+
467
+ Response format (multipart):
468
+ - Part 0: Header (22 bytes): protocol(1), type(1), unique_id(4), tree_id(16)
469
+ - Part 1: Status data: repeated (uid: uint16, status: uint8) tuples
470
+ """
471
+ if not parts or len(parts) < 2:
472
+ return
473
+
474
+ # Part 1 contains the status data
475
+ data = parts[1]
476
+ if len(data) < 3:
477
+ return
478
+
479
+ try:
480
+ offset = 0
481
+ changes = []
482
+
483
+ # Parse status updates: (uid: uint16, status: uint8) pairs
484
+ while offset + 3 <= len(data):
485
+ uid = struct.unpack('<H', data[offset:offset+2])[0]
486
+ status_val = data[offset+2]
487
+ offset += 3
488
+
489
+ status_str = NodeStatus.to_string(status_val)
490
+
491
+ # Only report changes
492
+ if self._last_statuses.get(uid) != status_str:
493
+ self._last_statuses[uid] = status_str
494
+
495
+ # Get node info if available
496
+ node_name = ""
497
+ node_tag = ""
498
+ if self._current_tree and uid in self._current_tree.nodes:
499
+ node = self._current_tree.nodes[uid]
500
+ node_name = node.name
501
+ node_tag = node.tag
502
+
503
+ self._log_info(f" Node {uid} ({node_name}): -> {status_str}")
504
+
505
+ changes.append({
506
+ 'uid': uid,
507
+ 'name': node_name,
508
+ 'tag': node_tag,
509
+ 'status': status_str
510
+ })
511
+
512
+ if changes:
513
+ event = {
514
+ 'type': 'bt_status',
515
+ 'timestamp': time.time(),
516
+ 'tree_id': tree_id or (self._current_tree.tree_id if self._current_tree else None),
517
+ 'changes': changes
518
+ }
519
+
520
+ self._log_info(f"Status update: {len(changes)} changes")
521
+ self._log_info(f"Sending status event: {json.dumps(event, indent=2)}")
522
+ self._event_callback(event)
523
+
524
+ except Exception as e:
525
+ self._log_error(f"Error parsing status message: {e}")
526
+
527
+ def _handle_breakpoint_message(self, data: bytes):
528
+ """Handle breakpoint messages from Groot2."""
529
+ try:
530
+ # Breakpoint data is typically raw bytes that can be hex-encoded
531
+ parts = []
532
+
533
+ # Split into chunks if needed (some protocols send multi-part)
534
+ chunk_size = 64
535
+ for i in range(0, len(data), chunk_size):
536
+ chunk = data[i:i+chunk_size]
537
+ parts.append(chunk.hex())
538
+
539
+ event = {
540
+ 'type': 'bt_breakpoint',
541
+ 'timestamp': time.time(),
542
+ 'parts': parts
543
+ }
544
+
545
+ self._log_debug("Breakpoint event received")
546
+ self._event_callback(event)
547
+
548
+ except Exception as e:
549
+ self._log_error(f"Error parsing breakpoint message: {e}")
550
+
551
+
552
+ # For testing standalone
553
+ if __name__ == "__main__":
554
+ def print_event(event):
555
+ print(f"EVENT: {json.dumps(event, indent=2)}")
556
+
557
+ collector = BTCollector(print_event)
558
+ collector.start()
559
+
560
+ try:
561
+ while True:
562
+ time.sleep(1)
563
+ except KeyboardInterrupt:
564
+ collector.stop()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: osiris_agent
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: OSIRIS agent for ROS2/Humble
5
5
  Home-page: https://github.com/nicolaselielll/osiris_agent
6
6
  Author: Nicolas Tuomaala
@@ -17,6 +17,7 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: websockets
19
19
  Requires-Dist: psutil
20
+ Requires-Dist: pyzmq
20
21
  Provides-Extra: ros
21
22
  Requires-Dist: rclpy; extra == "ros"
22
23
  Dynamic: author
@@ -3,6 +3,7 @@ README.md
3
3
  setup.py
4
4
  osiris_agent/__init__.py
5
5
  osiris_agent/agent_node.py
6
+ osiris_agent/bt_collector.py
6
7
  osiris_agent.egg-info/PKG-INFO
7
8
  osiris_agent.egg-info/SOURCES.txt
8
9
  osiris_agent.egg-info/dependency_links.txt
@@ -1,5 +1,6 @@
1
1
  websockets
2
2
  psutil
3
+ pyzmq
3
4
 
4
5
  [ros]
5
6
  rclpy
@@ -6,7 +6,7 @@ long_description = (HERE / "README.md").read_text(encoding="utf-8")
6
6
 
7
7
  setup(
8
8
  name='osiris_agent',
9
- version='0.1.2',
9
+ version='0.1.4',
10
10
  description='OSIRIS agent for ROS2/Humble',
11
11
  long_description=long_description,
12
12
  long_description_content_type="text/markdown",
@@ -27,6 +27,7 @@ setup(
27
27
  install_requires=[
28
28
  'websockets',
29
29
  'psutil',
30
+ 'pyzmq',
30
31
  ],
31
32
  extras_require={
32
33
  'ros': ['rclpy'],
File without changes
File without changes
File without changes