modlink_sdk
Public exports for driver authors using the ModLink SDK.
1"""Public exports for driver authors using the ModLink SDK.""" 2 3from .driver import ( 4 Driver, 5 DriverContext, 6 DriverFactory, 7 LoopDriver, 8) 9from .models import ( 10 FrameEnvelope, 11 PayloadType, 12 SearchResult, 13 StreamDescriptor, 14) 15 16__all__ = [ 17 "Driver", 18 "DriverContext", 19 "DriverFactory", 20 "LoopDriver", 21 "FrameEnvelope", 22 "PayloadType", 23 "SearchResult", 24 "StreamDescriptor", 25]
49class Driver: 50 """Base class for all ModLink drivers. 51 52 A driver instance represents one host-managed device endpoint. The host 53 loads the driver through a zero-argument factory, reads its static 54 metadata, and then executes lifecycle methods on a dedicated worker 55 thread. 56 57 General contract: 58 59 - ``device_id`` must be stable, non-empty, and use ``name.XX`` form. 60 - ``descriptors()`` must describe every stream the driver may emit. 61 - control methods are synchronous and run on the driver worker thread. 62 - ``start_streaming()`` should return quickly after streaming begins. 63 - one driver instance manages at most one active device connection. 64 """ 65 66 supported_providers: tuple[str, ...] = () 67 """Provider identifiers accepted by ``search()``.""" 68 69 @property 70 def device_id(self) -> str: 71 raise NotImplementedError(f"{type(self).__name__} must implement device_id") 72 73 def __init__(self) -> None: 74 self._context: DriverContext | None = None 75 76 @property 77 def display_name(self) -> str: 78 return self.device_id 79 80 def bind(self, context: DriverContext) -> None: 81 """Attach the host runtime context to this driver instance.""" 82 self._context = context 83 84 def on_runtime_started(self) -> None: 85 """Optional hook called after the driver worker thread starts.""" 86 87 def descriptors(self) -> list[StreamDescriptor]: 88 raise NotImplementedError(f"{type(self).__name__} must implement descriptors") 89 90 def on_shutdown(self) -> None: 91 """Optional hook called before the driver worker thread exits. 92 93 Override to release resources, close connections, etc. 94 """ 95 96 def emit_frame(self, frame: FrameEnvelope) -> bool: 97 return self._require_context().emit_frame(frame) 98 99 def emit_connection_lost(self, detail: object) -> None: 100 self._require_context().emit_connection_lost(detail) 101 102 def _require_context(self) -> DriverContext: 103 if self._context is None: 104 raise RuntimeError("driver is not bound to a runtime context") 105 return self._context 106 107 def search(self, provider: str) -> list[SearchResult]: 108 raise NotImplementedError(f"{type(self).__name__} must implement search") 109 110 def connect_device(self, config: SearchResult) -> None: 111 raise NotImplementedError( 112 f"{type(self).__name__} must implement connect_device if it supports connecting" 113 ) 114 115 def disconnect_device(self) -> None: 116 raise NotImplementedError( 117 f"{type(self).__name__} must implement disconnect_device if it supports connecting" 118 ) 119 120 def start_streaming(self) -> None: 121 raise NotImplementedError(f"{type(self).__name__} must implement start_streaming") 122 123 def stop_streaming(self) -> None: 124 raise NotImplementedError(f"{type(self).__name__} must implement stop_streaming")
Base class for all ModLink drivers.
A driver instance represents one host-managed device endpoint. The host loads the driver through a zero-argument factory, reads its static metadata, and then executes lifecycle methods on a dedicated worker thread.
General contract:
device_idmust be stable, non-empty, and usename.XXform.descriptors()must describe every stream the driver may emit.- control methods are synchronous and run on the driver worker thread.
start_streaming()should return quickly after streaming begins.- one driver instance manages at most one active device connection.
80 def bind(self, context: DriverContext) -> None: 81 """Attach the host runtime context to this driver instance.""" 82 self._context = context
Attach the host runtime context to this driver instance.
84 def on_runtime_started(self) -> None: 85 """Optional hook called after the driver worker thread starts."""
Optional hook called after the driver worker thread starts.
90 def on_shutdown(self) -> None: 91 """Optional hook called before the driver worker thread exits. 92 93 Override to release resources, close connections, etc. 94 """
Optional hook called before the driver worker thread exits.
Override to release resources, close connections, etc.
12class DriverContext: 13 """Runtime callbacks exposed to one driver instance. 14 15 The host may close a context during shutdown. After closure, late frames 16 and late connection-lost notifications are ignored so helper threads 17 cannot mutate host state after teardown. 18 """ 19 20 def __init__( 21 self, 22 *, 23 frame_sink: Callable[[FrameEnvelope], object | None], 24 connection_lost_sink: Callable[[object], None], 25 ) -> None: 26 self._frame_sink = frame_sink 27 self._connection_lost_sink = connection_lost_sink 28 self._lock = RLock() 29 self._closed = False 30 31 def emit_frame(self, frame: FrameEnvelope) -> bool: 32 with self._lock: 33 if self._closed: 34 return False 35 result = self._frame_sink(frame) 36 return result is not False 37 38 def emit_connection_lost(self, detail: object) -> None: 39 with self._lock: 40 if self._closed: 41 return 42 self._connection_lost_sink(detail) 43 44 def _close(self) -> None: 45 with self._lock: 46 self._closed = True
Runtime callbacks exposed to one driver instance.
The host may close a context during shutdown. After closure, late frames and late connection-lost notifications are ignored so helper threads cannot mutate host state after teardown.
130class LoopDriver(Driver): 131 """Convenience base class for bounded polling drivers with a helper thread. 132 133 This helper favors simple driver code over strict shutdown guarantees. 134 ``loop()`` should stay bounded and return quickly. Long blocking or 135 non-interruptible I/O belongs in a custom ``Driver`` implementation. 136 The host only guarantees that frames and connection-lost callbacks are 137 ignored after shutdown; it does not guarantee the helper thread has 138 already finished by the time teardown returns. 139 """ 140 141 loop_interval_ms = 10 142 143 def __init__(self) -> None: 144 super().__init__() 145 self._loop_lock = RLock() 146 self._loop_thread: Thread | None = None 147 self._loop_stop: Event | None = None 148 149 @property 150 def is_looping(self) -> bool: 151 with self._loop_lock: 152 thread = self._loop_thread 153 return thread is not None and thread.is_alive() 154 155 def start_streaming(self) -> None: 156 with self._loop_lock: 157 thread = self._loop_thread 158 if thread is not None and thread.is_alive(): 159 return 160 self.on_loop_started() 161 stop_event = Event() 162 thread = Thread( 163 target=self._run_loop_thread, 164 args=(stop_event,), 165 name=f"modlink.loop.{self.device_id.strip()}", 166 daemon=True, 167 ) 168 with self._loop_lock: 169 self._loop_stop = stop_event 170 self._loop_thread = thread 171 thread.start() 172 173 def stop_streaming(self) -> None: 174 with self._loop_lock: 175 stop_event = self._loop_stop 176 thread = self._loop_thread 177 if stop_event is None or thread is None: 178 return 179 stop_event.set() 180 if thread is not current_thread(): 181 thread.join(timeout=2.0) 182 183 def on_loop_started(self) -> None: 184 """Optional hook called once before loop execution starts.""" 185 186 def on_loop_stopped(self) -> None: 187 """Optional hook called once after loop execution stops.""" 188 189 def loop(self) -> None: 190 raise NotImplementedError(f"{type(self).__name__} must implement loop") 191 192 def on_shutdown(self) -> None: 193 """Best-effort shutdown hook for the helper loop thread.""" 194 self.stop_streaming() 195 196 def _run_loop_thread(self, stop_event: Event) -> None: 197 interval_seconds = max(0.0, float(self.loop_interval_ms)) / 1000.0 198 try: 199 while not stop_event.wait(interval_seconds): 200 self.loop() 201 except Exception as exc: 202 self.emit_connection_lost(f"LOOP_FAILED: {type(exc).__name__}: {exc}") 203 finally: 204 with self._loop_lock: 205 if self._loop_stop is stop_event: 206 self._loop_stop = None 207 if self._loop_thread is current_thread(): 208 self._loop_thread = None 209 self.on_loop_stopped()
Convenience base class for bounded polling drivers with a helper thread.
This helper favors simple driver code over strict shutdown guarantees.
loop() should stay bounded and return quickly. Long blocking or
non-interruptible I/O belongs in a custom Driver implementation.
The host only guarantees that frames and connection-lost callbacks are
ignored after shutdown; it does not guarantee the helper thread has
already finished by the time teardown returns.
155 def start_streaming(self) -> None: 156 with self._loop_lock: 157 thread = self._loop_thread 158 if thread is not None and thread.is_alive(): 159 return 160 self.on_loop_started() 161 stop_event = Event() 162 thread = Thread( 163 target=self._run_loop_thread, 164 args=(stop_event,), 165 name=f"modlink.loop.{self.device_id.strip()}", 166 daemon=True, 167 ) 168 with self._loop_lock: 169 self._loop_stop = stop_event 170 self._loop_thread = thread 171 thread.start()
183 def on_loop_started(self) -> None: 184 """Optional hook called once before loop execution starts."""
Optional hook called once before loop execution starts.
186 def on_loop_stopped(self) -> None: 187 """Optional hook called once after loop execution stops."""
Optional hook called once after loop execution stops.
41@dataclass(slots=True) 42class FrameEnvelope: 43 """One emitted payload chunk from a driver. 44 45 Drivers emit ``FrameEnvelope`` objects through their bound runtime 46 context. The host forwards them to the stream bus, recording backends, and 47 UI consumers. 48 """ 49 50 device_id: str 51 """Identifier of the device that produced this payload.""" 52 53 stream_key: str 54 """Device-local stream key for this payload, such as ``eeg`` or ``video``.""" 55 56 stream_id: str = field(init=False) 57 """Derived stable identifier of the stream that produced this payload.""" 58 59 timestamp_ns: int 60 """Driver-supplied timestamp in nanoseconds.""" 61 62 data: np.ndarray 63 """Payload array for this emitted chunk. 64 65 The expected array shape depends on the matching ``StreamDescriptor``. 66 """ 67 68 seq: int | None = None 69 """Optional monotonically increasing sequence number.""" 70 71 def __post_init__(self) -> None: 72 self.device_id = normalize_device_id(self.device_id) 73 self.stream_key = normalize_stream_key(self.stream_key) 74 self.stream_id = make_stream_id(self.device_id, self.stream_key)
One emitted payload chunk from a driver.
Drivers emit FrameEnvelope objects through their bound runtime
context. The host forwards them to the stream bus, recording backends, and
UI consumers.
Payload array for this emitted chunk.
The expected array shape depends on the matching StreamDescriptor.
16@dataclass(slots=True) 17class SearchResult: 18 """One discovery candidate returned by ``Driver.search()``. 19 20 Hosts use ``title`` and ``subtitle`` for presentation. ``extra`` is owned 21 by the driver and is passed back to ``connect_device()`` unchanged. 22 """ 23 24 title: str 25 """Primary label shown to the user.""" 26 27 subtitle: str = "" 28 """Optional secondary label shown to the user.""" 29 30 device_id: str | None = None 31 """Optional canonical device identifier suggested by the driver.""" 32 33 extra: dict[str, Any] = field(default_factory=dict) 34 """Driver-owned JSON-friendly payload for later connection.""" 35 36 def __post_init__(self) -> None: 37 if self.device_id is not None: 38 self.device_id = normalize_device_id(self.device_id)
One discovery candidate returned by Driver.search().
Hosts use title and subtitle for presentation. extra is owned
by the driver and is passed back to connect_device() unchanged.
77@dataclass(slots=True) 78class StreamDescriptor: 79 """Static metadata describing one stream exposed by a driver. 80 81 Hosts may read descriptors before a device is connected. The returned 82 values should remain stable for the lifetime of the driver instance. 83 """ 84 85 device_id: str 86 """Stable device identifier in ``name.XX`` form.""" 87 88 stream_key: str 89 """Device-local stream key, such as ``eeg``, ``accel``, or ``audio``.""" 90 91 stream_id: str = field(init=False) 92 """Derived stable identifier for this stream.""" 93 94 payload_type: PayloadType 95 """Payload type used to interpret ``FrameEnvelope.data``.""" 96 97 nominal_sample_rate_hz: float 98 """Nominal sample rate in Hz.""" 99 100 chunk_size: int 101 """Expected number of samples or frames per emitted chunk.""" 102 103 channel_names: tuple[str, ...] = () 104 """Optional channel labels, usually matching axis 0 for signal payloads.""" 105 106 display_name: str | None = None 107 """Optional human-readable stream label.""" 108 109 metadata: dict[str, Any] = field(default_factory=dict) 110 """Additional metadata, including payload-specific fields such as ``unit``.""" 111 112 def __post_init__(self) -> None: 113 self.device_id = normalize_device_id(self.device_id) 114 self.stream_key = normalize_stream_key(self.stream_key) 115 self.stream_id = make_stream_id(self.device_id, self.stream_key) 116 self.metadata = dict(self.metadata)
Static metadata describing one stream exposed by a driver.
Hosts may read descriptors before a device is connected. The returned values should remain stable for the lifetime of the driver instance.