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.
96@dataclass(slots=True) 97class StreamDescriptor: 98 """Static metadata describing one stream exposed by a driver. 99 100 Hosts may read descriptors before a device is connected. The returned 101 values should remain stable for the lifetime of the driver instance. 102 """ 103 104 device_id: str 105 """Stable device identifier in ``name.XX`` form.""" 106 107 stream_key: str 108 """Device-local stream key, such as ``eeg``, ``accel``, or ``audio``.""" 109 110 stream_id: str = field(init=False) 111 """Derived stable identifier for this stream.""" 112 113 payload_type: PayloadType 114 """Payload type used to interpret ``FrameEnvelope.data``.""" 115 116 nominal_sample_rate_hz: float 117 """Nominal sample rate in Hz.""" 118 119 chunk_size: int 120 """Expected number of samples or frames per emitted chunk.""" 121 122 channel_names: tuple[str, ...] = () 123 """Optional channel labels, usually matching axis 0 for signal payloads.""" 124 125 display_name: str | None = None 126 """Optional human-readable stream label.""" 127 128 metadata: dict[str, Any] = field(default_factory=dict) 129 """Additional metadata, including payload-specific fields such as ``unit``.""" 130 131 def __post_init__(self) -> None: 132 self.device_id = normalize_device_id(self.device_id) 133 self.stream_key = normalize_stream_key(self.stream_key) 134 self.stream_id = make_stream_id(self.device_id, self.stream_key) 135 self.metadata = dict(self.metadata) 136 _validate_channel_names(self.channel_names)
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.