rocket-welder-sdk 1.1.33__py3-none-any.whl → 1.1.34__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.
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
17
17
  from rocket_welder_sdk.keypoints_protocol import IKeyPointsWriter
18
18
  from rocket_welder_sdk.segmentation_result import SegmentationResultWriter
19
19
 
20
- from .schema import KeyPoint, SegmentClass
20
+ from .schema import KeyPointDefinition, SegmentClass
21
21
 
22
22
  # Type aliases
23
23
  Point = Tuple[int, int]
@@ -37,12 +37,12 @@ class IKeyPointsDataContext(ABC):
37
37
  pass
38
38
 
39
39
  @abstractmethod
40
- def add(self, point: KeyPoint, x: int, y: int, confidence: float) -> None:
40
+ def add(self, point: KeyPointDefinition, x: int, y: int, confidence: float) -> None:
41
41
  """
42
42
  Add a keypoint detection for this frame.
43
43
 
44
44
  Args:
45
- point: KeyPoint from schema definition
45
+ point: KeyPointDefinition from schema definition
46
46
  x: X coordinate in pixels
47
47
  y: Y coordinate in pixels
48
48
  confidence: Detection confidence (0.0 to 1.0)
@@ -50,17 +50,22 @@ class IKeyPointsDataContext(ABC):
50
50
  pass
51
51
 
52
52
  @abstractmethod
53
- def add_point(self, point: KeyPoint, position: Point, confidence: float) -> None:
53
+ def add_point(self, point: KeyPointDefinition, position: Point, confidence: float) -> None:
54
54
  """
55
55
  Add a keypoint detection using a Point tuple.
56
56
 
57
57
  Args:
58
- point: KeyPoint from schema definition
58
+ point: KeyPointDefinition from schema definition
59
59
  position: (x, y) tuple
60
60
  confidence: Detection confidence (0.0 to 1.0)
61
61
  """
62
62
  pass
63
63
 
64
+ @abstractmethod
65
+ def commit(self) -> None:
66
+ """Commit the context (called automatically when delegate returns)."""
67
+ pass
68
+
64
69
 
65
70
  class ISegmentationDataContext(ABC):
66
71
  """
@@ -92,6 +97,11 @@ class ISegmentationDataContext(ABC):
92
97
  """
93
98
  pass
94
99
 
100
+ @abstractmethod
101
+ def commit(self) -> None:
102
+ """Commit the context (called automatically when delegate returns)."""
103
+ pass
104
+
95
105
 
96
106
  class KeyPointsDataContext(IKeyPointsDataContext):
97
107
  """Implementation of keypoints data context."""
@@ -101,8 +111,6 @@ class KeyPointsDataContext(IKeyPointsDataContext):
101
111
  frame_id: int,
102
112
  writer: IKeyPointsWriter,
103
113
  ) -> None:
104
- from .schema import KeyPoint # noqa: F401
105
-
106
114
  self._frame_id = frame_id
107
115
  self._writer = writer
108
116
 
@@ -110,11 +118,11 @@ class KeyPointsDataContext(IKeyPointsDataContext):
110
118
  def frame_id(self) -> int:
111
119
  return self._frame_id
112
120
 
113
- def add(self, point: KeyPoint, x: int, y: int, confidence: float) -> None:
121
+ def add(self, point: KeyPointDefinition, x: int, y: int, confidence: float) -> None:
114
122
  """Add a keypoint detection for this frame."""
115
123
  self._writer.append(point.id, x, y, confidence)
116
124
 
117
- def add_point(self, point: KeyPoint, position: Point, confidence: float) -> None:
125
+ def add_point(self, point: KeyPointDefinition, position: Point, confidence: float) -> None:
118
126
  """Add a keypoint detection using a Point tuple."""
119
127
  self._writer.append_point(point.id, position, confidence)
120
128
 
@@ -131,8 +139,6 @@ class SegmentationDataContext(ISegmentationDataContext):
131
139
  frame_id: int,
132
140
  writer: SegmentationResultWriter,
133
141
  ) -> None:
134
- from .schema import SegmentClass # noqa: F401
135
-
136
142
  self._frame_id = frame_id
137
143
  self._writer = writer
138
144
 
@@ -10,11 +10,11 @@ from __future__ import annotations
10
10
  import json
11
11
  from abc import ABC, abstractmethod
12
12
  from dataclasses import dataclass
13
- from typing import Dict, List
13
+ from typing import Dict, List, Any
14
14
 
15
15
 
16
16
  @dataclass(frozen=True)
17
- class KeyPoint:
17
+ class KeyPointDefinition:
18
18
  """
19
19
  A keypoint definition with ID and name.
20
20
 
@@ -26,7 +26,7 @@ class KeyPoint:
26
26
  name: str
27
27
 
28
28
  def __str__(self) -> str:
29
- return f"KeyPoint({self.id}, '{self.name}')"
29
+ return f"KeyPointDefinition({self.id}, '{self.name}')"
30
30
 
31
31
 
32
32
  @dataclass(frozen=True)
@@ -54,7 +54,7 @@ class IKeyPointsSchema(ABC):
54
54
  """
55
55
 
56
56
  @abstractmethod
57
- def define_point(self, name: str) -> KeyPoint:
57
+ def define_point(self, name: str) -> KeyPointDefinition:
58
58
  """
59
59
  Define a new keypoint.
60
60
 
@@ -62,13 +62,13 @@ class IKeyPointsSchema(ABC):
62
62
  name: Human-readable name for the keypoint (e.g., "nose", "left_eye")
63
63
 
64
64
  Returns:
65
- KeyPoint handle for use with IKeyPointsDataContext.add()
65
+ KeyPointDefinition handle for use with IKeyPointsDataContext.add()
66
66
  """
67
67
  pass
68
68
 
69
69
  @property
70
70
  @abstractmethod
71
- def defined_points(self) -> List[KeyPoint]:
71
+ def defined_points(self) -> List[KeyPointDefinition]:
72
72
  """Get all defined keypoints."""
73
73
  pass
74
74
 
@@ -116,34 +116,41 @@ class KeyPointsSchema(IKeyPointsSchema):
116
116
  """Implementation of keypoints schema."""
117
117
 
118
118
  def __init__(self) -> None:
119
- self._points: Dict[str, KeyPoint] = {}
119
+ self._points: Dict[str, KeyPointDefinition] = {}
120
120
  self._next_id = 0
121
121
 
122
- def define_point(self, name: str) -> KeyPoint:
122
+ def define_point(self, name: str) -> KeyPointDefinition:
123
123
  """Define a new keypoint."""
124
124
  if name in self._points:
125
125
  raise ValueError(f"Keypoint '{name}' already defined")
126
126
 
127
- point = KeyPoint(id=self._next_id, name=name)
127
+ point = KeyPointDefinition(id=self._next_id, name=name)
128
128
  self._points[name] = point
129
129
  self._next_id += 1
130
130
  return point
131
131
 
132
132
  @property
133
- def defined_points(self) -> List[KeyPoint]:
133
+ def defined_points(self) -> List[KeyPointDefinition]:
134
134
  """Get all defined keypoints."""
135
135
  return list(self._points.values())
136
136
 
137
137
  def get_metadata_json(self) -> str:
138
- """Get JSON metadata for serialization."""
139
- return json.dumps(
140
- {
141
- "version": "1.0",
142
- "compute_module_name": "",
143
- "points": {p.name: p.id for p in self._points.values()},
144
- },
145
- indent=2,
146
- )
138
+ """
139
+ Get JSON metadata for serialization.
140
+
141
+ Format matches C# SDK:
142
+ {
143
+ "version": 1,
144
+ "type": "keypoints",
145
+ "points": [{"id": 0, "name": "nose"}, ...]
146
+ }
147
+ """
148
+ metadata: Dict[str, Any] = {
149
+ "version": 1,
150
+ "type": "keypoints",
151
+ "points": [{"id": p.id, "name": p.name} for p in self._points.values()],
152
+ }
153
+ return json.dumps(metadata, indent=2)
147
154
 
148
155
 
149
156
  class SegmentationSchema(ISegmentationSchema):
@@ -170,11 +177,21 @@ class SegmentationSchema(ISegmentationSchema):
170
177
  return list(self._classes.values())
171
178
 
172
179
  def get_metadata_json(self) -> str:
173
- """Get JSON metadata for serialization."""
174
- return json.dumps(
175
- {
176
- "version": "1.0",
177
- "classes": {str(c.class_id): c.name for c in self._classes.values()},
178
- },
179
- indent=2,
180
- )
180
+ """
181
+ Get JSON metadata for serialization.
182
+
183
+ Format matches C# SDK:
184
+ {
185
+ "version": 1,
186
+ "type": "segmentation",
187
+ "classes": [{"classId": 1, "name": "person"}, ...]
188
+ }
189
+ """
190
+ metadata: Dict[str, Any] = {
191
+ "version": 1,
192
+ "type": "segmentation",
193
+ "classes": [
194
+ {"classId": c.class_id, "name": c.name} for c in self._classes.values()
195
+ ],
196
+ }
197
+ return json.dumps(metadata, indent=2)
@@ -1,103 +1,204 @@
1
1
  """
2
- Transport protocol types with composable + operator.
2
+ Unified transport protocol as a value type.
3
3
 
4
- Allows building transport protocols like:
5
- protocol = Transport.Nng + Transport.Push + Transport.Ipc
6
- # Results in TransportProtocol("nng", "push", "ipc")
4
+ Supports: file://, socket://, nng+push+ipc://, nng+push+tcp://, etc.
5
+
6
+ Examples:
7
+ file:///home/user/output.bin - absolute file path
8
+ socket:///tmp/my.sock - Unix domain socket
9
+ nng+push+ipc://tmp/keypoints - NNG Push over IPC
10
+ nng+push+tcp://host:5555 - NNG Push over TCP
7
11
  """
8
12
 
9
13
  from __future__ import annotations
10
14
 
11
- from dataclasses import dataclass
12
- from typing import Optional
15
+ from enum import Enum, auto
16
+ from typing import ClassVar, Dict, Optional
13
17
 
14
18
 
15
- @dataclass(frozen=True)
16
- class MessagingLibrary:
17
- """Messaging library (nng, zeromq, etc.)."""
19
+ class TransportKind(Enum):
20
+ """Transport kind enumeration."""
18
21
 
19
- name: str
22
+ FILE = auto()
23
+ """File output."""
20
24
 
21
- def __add__(self, pattern: MessagingPattern) -> TransportBuilder:
22
- """Compose with messaging pattern: Nng + Push."""
23
- return TransportBuilder(library=self, pattern=pattern)
25
+ SOCKET = auto()
26
+ """Unix domain socket (direct, no messaging library)."""
24
27
 
25
- def __str__(self) -> str:
26
- return self.name
28
+ NNG_PUSH_IPC = auto()
29
+ """NNG Push over IPC."""
27
30
 
31
+ NNG_PUSH_TCP = auto()
32
+ """NNG Push over TCP."""
28
33
 
29
- @dataclass(frozen=True)
30
- class MessagingPattern:
31
- """Messaging pattern (push/pull, pub/sub, etc.)."""
34
+ NNG_PULL_IPC = auto()
35
+ """NNG Pull over IPC."""
32
36
 
33
- name: str
37
+ NNG_PULL_TCP = auto()
38
+ """NNG Pull over TCP."""
34
39
 
35
- def __str__(self) -> str:
36
- return self.name
40
+ NNG_PUB_IPC = auto()
41
+ """NNG Pub over IPC."""
37
42
 
43
+ NNG_PUB_TCP = auto()
44
+ """NNG Pub over TCP."""
38
45
 
39
- @dataclass(frozen=True)
40
- class TransportLayer:
41
- """Transport layer (ipc, tcp, etc.)."""
46
+ NNG_SUB_IPC = auto()
47
+ """NNG Sub over IPC."""
42
48
 
43
- name: str
44
- uri_prefix: str
49
+ NNG_SUB_TCP = auto()
50
+ """NNG Sub over TCP."""
45
51
 
46
- def __str__(self) -> str:
47
- return self.name
48
52
 
53
+ class TransportProtocol:
54
+ """
55
+ Unified transport protocol specification as a value type.
56
+
57
+ Supports: file://, socket://, nng+push+ipc://, nng+push+tcp://, etc.
58
+ """
59
+
60
+ # Predefined protocols
61
+ File: TransportProtocol
62
+ Socket: TransportProtocol
63
+ NngPushIpc: TransportProtocol
64
+ NngPushTcp: TransportProtocol
65
+ NngPullIpc: TransportProtocol
66
+ NngPullTcp: TransportProtocol
67
+ NngPubIpc: TransportProtocol
68
+ NngPubTcp: TransportProtocol
69
+ NngSubIpc: TransportProtocol
70
+ NngSubTcp: TransportProtocol
71
+
72
+ _SCHEMA_MAP: ClassVar[Dict[str, TransportKind]] = {
73
+ "file": TransportKind.FILE,
74
+ "socket": TransportKind.SOCKET,
75
+ "nng+push+ipc": TransportKind.NNG_PUSH_IPC,
76
+ "nng+push+tcp": TransportKind.NNG_PUSH_TCP,
77
+ "nng+pull+ipc": TransportKind.NNG_PULL_IPC,
78
+ "nng+pull+tcp": TransportKind.NNG_PULL_TCP,
79
+ "nng+pub+ipc": TransportKind.NNG_PUB_IPC,
80
+ "nng+pub+tcp": TransportKind.NNG_PUB_TCP,
81
+ "nng+sub+ipc": TransportKind.NNG_SUB_IPC,
82
+ "nng+sub+tcp": TransportKind.NNG_SUB_TCP,
83
+ }
84
+
85
+ _KIND_TO_SCHEMA: ClassVar[Dict[TransportKind, str]] = {}
86
+
87
+ def __init__(self, kind: TransportKind, schema: str) -> None:
88
+ self._kind = kind
89
+ self._schema = schema
90
+
91
+ @property
92
+ def kind(self) -> TransportKind:
93
+ """The transport kind."""
94
+ return self._kind
49
95
 
50
- @dataclass(frozen=True)
51
- class TransportBuilder:
52
- """Builder for constructing transport protocols."""
96
+ @property
97
+ def schema(self) -> str:
98
+ """The schema string (e.g., 'file', 'socket', 'nng+push+ipc')."""
99
+ return self._schema
53
100
 
54
- library: MessagingLibrary
55
- pattern: MessagingPattern
101
+ # Classification properties
56
102
 
57
- def __add__(self, layer: TransportLayer) -> TransportProtocol:
58
- """Compose with transport layer: (Nng + Push) + Ipc."""
59
- return TransportProtocol(library=self.library, pattern=self.pattern, layer=layer)
103
+ @property
104
+ def is_file(self) -> bool:
105
+ """True if this is a file transport."""
106
+ return self._kind == TransportKind.FILE
60
107
 
61
- def __str__(self) -> str:
62
- return f"{self.library}+{self.pattern}"
108
+ @property
109
+ def is_socket(self) -> bool:
110
+ """True if this is a Unix socket transport."""
111
+ return self._kind == TransportKind.SOCKET
63
112
 
113
+ @property
114
+ def is_nng(self) -> bool:
115
+ """True if this is any NNG-based transport."""
116
+ return self._kind in {
117
+ TransportKind.NNG_PUSH_IPC,
118
+ TransportKind.NNG_PUSH_TCP,
119
+ TransportKind.NNG_PULL_IPC,
120
+ TransportKind.NNG_PULL_TCP,
121
+ TransportKind.NNG_PUB_IPC,
122
+ TransportKind.NNG_PUB_TCP,
123
+ TransportKind.NNG_SUB_IPC,
124
+ TransportKind.NNG_SUB_TCP,
125
+ }
64
126
 
65
- @dataclass(frozen=True)
66
- class TransportProtocol:
67
- """Complete transport protocol specification."""
127
+ @property
128
+ def is_push(self) -> bool:
129
+ """True if this is a Push pattern."""
130
+ return self._kind in {TransportKind.NNG_PUSH_IPC, TransportKind.NNG_PUSH_TCP}
131
+
132
+ @property
133
+ def is_pull(self) -> bool:
134
+ """True if this is a Pull pattern."""
135
+ return self._kind in {TransportKind.NNG_PULL_IPC, TransportKind.NNG_PULL_TCP}
136
+
137
+ @property
138
+ def is_pub(self) -> bool:
139
+ """True if this is a Pub pattern."""
140
+ return self._kind in {TransportKind.NNG_PUB_IPC, TransportKind.NNG_PUB_TCP}
68
141
 
69
- library: MessagingLibrary
70
- pattern: MessagingPattern
71
- layer: TransportLayer
142
+ @property
143
+ def is_sub(self) -> bool:
144
+ """True if this is a Sub pattern."""
145
+ return self._kind in {TransportKind.NNG_SUB_IPC, TransportKind.NNG_SUB_TCP}
146
+
147
+ @property
148
+ def is_ipc(self) -> bool:
149
+ """True if this uses IPC layer."""
150
+ return self._kind in {
151
+ TransportKind.NNG_PUSH_IPC,
152
+ TransportKind.NNG_PULL_IPC,
153
+ TransportKind.NNG_PUB_IPC,
154
+ TransportKind.NNG_SUB_IPC,
155
+ }
72
156
 
73
157
  @property
74
- def protocol_string(self) -> str:
75
- """Protocol string for parsing (e.g., 'nng+push+ipc')."""
76
- return f"{self.library}+{self.pattern}+{self.layer}"
158
+ def is_tcp(self) -> bool:
159
+ """True if this uses TCP layer."""
160
+ return self._kind in {
161
+ TransportKind.NNG_PUSH_TCP,
162
+ TransportKind.NNG_PULL_TCP,
163
+ TransportKind.NNG_PUB_TCP,
164
+ TransportKind.NNG_SUB_TCP,
165
+ }
77
166
 
78
167
  def create_nng_address(self, path_or_host: str) -> str:
79
168
  """
80
169
  Create the NNG address from a path/host.
81
170
 
82
- For IPC: adds leading "/" to make absolute path
83
- For TCP: uses as-is
171
+ For IPC: ipc:///path
172
+ For TCP: tcp://host:port
173
+
174
+ Raises:
175
+ ValueError: If this is not an NNG protocol.
84
176
  """
85
- if self.layer == Transport.Ipc and not path_or_host.startswith("/"):
86
- return f"{self.layer.uri_prefix}/{path_or_host}"
87
- return f"{self.layer.uri_prefix}{path_or_host}"
177
+ if not self.is_nng:
178
+ raise ValueError(f"Cannot create NNG address for {self._kind} transport")
88
179
 
89
- @property
90
- def is_push(self) -> bool:
91
- """Check if this is a push pattern."""
92
- return self.pattern == Transport.Push
180
+ if self.is_ipc:
181
+ # IPC paths need leading "/" for absolute paths
182
+ if not path_or_host.startswith("/"):
183
+ return f"ipc:///{path_or_host}"
184
+ return f"ipc://{path_or_host}"
93
185
 
94
- @property
95
- def is_pub(self) -> bool:
96
- """Check if this is a pub pattern."""
97
- return self.pattern == Transport.Pub
186
+ # TCP
187
+ return f"tcp://{path_or_host}"
98
188
 
99
189
  def __str__(self) -> str:
100
- return self.protocol_string
190
+ return self._schema
191
+
192
+ def __repr__(self) -> str:
193
+ return f"TransportProtocol({self._kind.name}, '{self._schema}')"
194
+
195
+ def __eq__(self, other: object) -> bool:
196
+ if isinstance(other, TransportProtocol):
197
+ return self._kind == other._kind
198
+ return False
199
+
200
+ def __hash__(self) -> int:
201
+ return hash(self._kind)
101
202
 
102
203
  @classmethod
103
204
  def parse(cls, s: str) -> TransportProtocol:
@@ -108,59 +209,30 @@ class TransportProtocol:
108
209
  return result
109
210
 
110
211
  @classmethod
111
- def try_parse(cls, s: str) -> Optional[TransportProtocol]:
212
+ def try_parse(cls, s: Optional[str]) -> Optional[TransportProtocol]:
112
213
  """Try to parse a protocol string."""
113
214
  if not s:
114
215
  return None
115
216
 
116
- parts = s.lower().split("+")
117
- if len(parts) != 3:
118
- return None
119
-
120
- # Parse library
121
- if parts[0] == "nng":
122
- library = Transport.Nng
123
- else:
124
- return None
125
-
126
- # Parse pattern
127
- if parts[1] == "push":
128
- pattern = Transport.Push
129
- elif parts[1] == "pull":
130
- pattern = Transport.Pull
131
- elif parts[1] == "pub":
132
- pattern = Transport.Pub
133
- elif parts[1] == "sub":
134
- pattern = Transport.Sub
135
- else:
217
+ schema = s.lower().strip()
218
+ kind = cls._SCHEMA_MAP.get(schema)
219
+ if kind is None:
136
220
  return None
137
221
 
138
- # Parse layer
139
- if parts[2] == "ipc":
140
- layer = Transport.Ipc
141
- elif parts[2] == "tcp":
142
- layer = Transport.Tcp
143
- else:
144
- return None
145
-
146
- return cls(library=library, pattern=pattern, layer=layer)
147
-
148
-
149
- class Transport:
150
- """Static helpers for building transport protocols using + operator."""
151
-
152
- # Messaging libraries
153
- Nng: MessagingLibrary = MessagingLibrary("nng")
222
+ return cls(kind, schema)
154
223
 
155
- # Messaging patterns
156
- Push: MessagingPattern = MessagingPattern("push")
157
- Pull: MessagingPattern = MessagingPattern("pull")
158
- Pub: MessagingPattern = MessagingPattern("pub")
159
- Sub: MessagingPattern = MessagingPattern("sub")
160
224
 
161
- # Transport layers
162
- Ipc: TransportLayer = TransportLayer("ipc", "ipc://")
163
- Tcp: TransportLayer = TransportLayer("tcp", "tcp://")
225
+ # Initialize predefined protocols
226
+ TransportProtocol.File = TransportProtocol(TransportKind.FILE, "file")
227
+ TransportProtocol.Socket = TransportProtocol(TransportKind.SOCKET, "socket")
228
+ TransportProtocol.NngPushIpc = TransportProtocol(TransportKind.NNG_PUSH_IPC, "nng+push+ipc")
229
+ TransportProtocol.NngPushTcp = TransportProtocol(TransportKind.NNG_PUSH_TCP, "nng+push+tcp")
230
+ TransportProtocol.NngPullIpc = TransportProtocol(TransportKind.NNG_PULL_IPC, "nng+pull+ipc")
231
+ TransportProtocol.NngPullTcp = TransportProtocol(TransportKind.NNG_PULL_TCP, "nng+pull+tcp")
232
+ TransportProtocol.NngPubIpc = TransportProtocol(TransportKind.NNG_PUB_IPC, "nng+pub+ipc")
233
+ TransportProtocol.NngPubTcp = TransportProtocol(TransportKind.NNG_PUB_TCP, "nng+pub+tcp")
234
+ TransportProtocol.NngSubIpc = TransportProtocol(TransportKind.NNG_SUB_IPC, "nng+sub+ipc")
235
+ TransportProtocol.NngSubTcp = TransportProtocol(TransportKind.NNG_SUB_TCP, "nng+sub+tcp")
164
236
 
165
- # File output (not a real transport)
166
- File: str = "file"
237
+ # Initialize reverse lookup map
238
+ TransportProtocol._KIND_TO_SCHEMA = {v: k for k, v in TransportProtocol._SCHEMA_MAP.items()}
@@ -16,6 +16,12 @@ from .connection_string import ConnectionMode, ConnectionString, Protocol
16
16
  from .controllers import DuplexShmController, IController, OneWayShmController
17
17
  from .frame_metadata import FrameMetadata # noqa: TC001 - used at runtime in callbacks
18
18
  from .opencv_controller import OpenCvController
19
+ from .session_id import (
20
+ get_configured_nng_urls,
21
+ get_nng_urls_from_env,
22
+ has_explicit_nng_urls,
23
+ )
24
+ from .transport.nng_transport import NngFrameSink
19
25
 
20
26
  if TYPE_CHECKING:
21
27
  import numpy.typing as npt
@@ -53,6 +59,9 @@ class RocketWelderClient:
53
59
  self._controller: Optional[IController] = None
54
60
  self._lock = threading.Lock()
55
61
 
62
+ # NNG publishers for streaming results (auto-created if SessionId env var is set)
63
+ self._nng_publishers: dict[str, NngFrameSink] = {}
64
+
56
65
  # Preview support
57
66
  self._preview_enabled = (
58
67
  self._connection.parameters.get("preview", "false").lower() == "true"
@@ -72,6 +81,50 @@ class RocketWelderClient:
72
81
  with self._lock:
73
82
  return self._controller is not None and self._controller.is_running
74
83
 
84
+ @property
85
+ def nng_publishers(self) -> dict[str, NngFrameSink]:
86
+ """Get NNG publishers for streaming results.
87
+
88
+ Returns:
89
+ Dictionary with 'segmentation', 'keypoints', 'actions' keys.
90
+ Empty if SessionId env var was not set at startup.
91
+
92
+ Example:
93
+ client.nng_publishers["segmentation"].write_frame(seg_data)
94
+ """
95
+ return self._nng_publishers
96
+
97
+ def _create_nng_publishers(self) -> None:
98
+ """Create NNG publishers for result streaming.
99
+
100
+ URLs are read from environment variables (preferred) or derived from SessionId (fallback).
101
+
102
+ Priority:
103
+ 1. Explicit URLs: SEGMENTATION_SINK_URL, KEYPOINTS_SINK_URL, ACTIONS_SINK_URL
104
+ 2. Derived from SessionId environment variable (backwards compatibility)
105
+ """
106
+ try:
107
+ urls = get_configured_nng_urls()
108
+
109
+ for name, url in urls.items():
110
+ sink = NngFrameSink.create_publisher(url)
111
+ self._nng_publishers[name] = sink
112
+ logger.info("NNG publisher ready: %s at %s", name, url)
113
+
114
+ # Log configuration summary
115
+ logger.info(
116
+ "NNG publishers configured: seg=%s, kp=%s, actions=%s",
117
+ urls.get("segmentation", "(not configured)"),
118
+ urls.get("keypoints", "(not configured)"),
119
+ urls.get("actions", "(not configured)"),
120
+ )
121
+ except ValueError as ex:
122
+ # No URLs configured - this is expected for containers that don't publish results
123
+ logger.debug("NNG publishers not configured: %s", ex)
124
+ except Exception as ex:
125
+ logger.warning("Failed to create NNG publishers: %s", ex)
126
+ # Don't fail start() - NNG is optional for backwards compatibility
127
+
75
128
  def get_metadata(self) -> Optional[GstMetadata]:
76
129
  """
77
130
  Get the current GStreamer metadata.
@@ -119,6 +172,21 @@ class RocketWelderClient:
119
172
  else:
120
173
  raise ValueError(f"Unsupported protocol: {self._connection.protocol}")
121
174
 
175
+ # Auto-create NNG publishers if URLs are configured
176
+ # (explicit URLs via SEGMENTATION_SINK_URL etc., or derived from SessionId)
177
+ if has_explicit_nng_urls():
178
+ self._create_nng_publishers()
179
+ else:
180
+ # Log that NNG is not configured (informational)
181
+ urls = get_nng_urls_from_env()
182
+ logger.info(
183
+ "NNG sink URLs not configured (this is normal if not publishing AI results). "
184
+ "seg=%s, kp=%s, actions=%s",
185
+ urls.get("segmentation") or "(not set)",
186
+ urls.get("keypoints") or "(not set)",
187
+ urls.get("actions") or "(not set)",
188
+ )
189
+
122
190
  # If preview is enabled, wrap the callback to capture frames
123
191
  if self._preview_enabled:
124
192
  self._original_callback = on_frame
@@ -189,6 +257,15 @@ class RocketWelderClient:
189
257
  if self._preview_enabled:
190
258
  self._preview_queue.put(None) # Sentinel value
191
259
 
260
+ # Clean up NNG publishers
261
+ for name, sink in self._nng_publishers.items():
262
+ try:
263
+ sink.close()
264
+ logger.debug("Closed NNG publisher: %s", name)
265
+ except Exception as ex:
266
+ logger.warning("Failed to close NNG publisher %s: %s", name, ex)
267
+ self._nng_publishers.clear()
268
+
192
269
  logger.info("RocketWelder client stopped")
193
270
 
194
271
  def show(self, cancellation_token: Optional[threading.Event] = None) -> None: