diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5232aab..bff8517 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ on: workflow_dispatch: env: - LSL_RELEASE_URL: "https://github.com/sccn/liblsl/releases/download/v1.17.7" - LSL_RELEASE: "1.17.7" + LSL_RELEASE_URL: "https://github.com/sccn/liblsl/releases/download/v1.18.0.b2" + LSL_RELEASE: "1.18.0" jobs: diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 5ef541b..7080aea 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -9,8 +9,8 @@ on: workflow_dispatch: env: - LSL_RELEASE_URL: "https://github.com/sccn/liblsl/releases/download/v1.17.7" - LSL_RELEASE: "1.17.7" + LSL_RELEASE_URL: "https://github.com/sccn/liblsl/releases/download/v1.18.0.b2" + LSL_RELEASE: "1.18.0" defaults: run: @@ -61,7 +61,7 @@ jobs: os: macos-latest arch: universal pyarch: x64 - asset: "lsl.xcframework.1.17.zip" + asset: "lsl.xcframework.1.18.zip" extract: | unzip xcframework.zip cp lsl.xcframework/macos-arm64_x86_64/lsl.framework/Versions/A/lsl src/pylsl/lib/liblsl.dylib diff --git a/src/pylsl/__init__.py b/src/pylsl/__init__.py index d44c93e..18ee601 100644 --- a/src/pylsl/__init__.py +++ b/src/pylsl/__init__.py @@ -30,6 +30,10 @@ from .util import proc_monotonize as proc_monotonize from .util import proc_threadsafe as proc_threadsafe from .util import proc_ALL as proc_ALL +from .util import transp_default as transp_default +from .util import transp_bufsize_samples as transp_bufsize_samples +from .util import transp_bufsize_thousandths as transp_bufsize_thousandths +from .util import transp_sync_blocking as transp_sync_blocking from .util import protocol_version as protocol_version from .util import library_version as library_version from .util import library_info as library_info diff --git a/src/pylsl/info.py b/src/pylsl/info.py index 001b91a..297ad79 100644 --- a/src/pylsl/info.py +++ b/src/pylsl/info.py @@ -210,6 +210,21 @@ def uid(self) -> str: """ return lib.lsl_get_uid(self.obj).decode("utf-8") + def reset_uid(self) -> str: + """Reset the stream's unique ID to a new random value and return it. + + Useful for generating a UID on a locally-constructed StreamInfo that + has not yet been associated with an outlet or inlet. + + Requires a liblsl that exposes lsl_reset_uid (liblsl >= 1.18.0). + """ + if not hasattr(lib, "lsl_reset_uid"): + raise NotImplementedError( + "lsl_reset_uid is not available in your liblsl version " + "(requires liblsl >= 1.18.0)." + ) + return lib.lsl_reset_uid(self.obj).decode("utf-8") + def session_id(self) -> str: """Session ID for the given stream. diff --git a/src/pylsl/lib/__init__.py b/src/pylsl/lib/__init__.py index b602743..76927ad 100644 --- a/src/pylsl/lib/__init__.py +++ b/src/pylsl/lib/__init__.py @@ -191,6 +191,13 @@ def find_liblsl_libraries(verbose=False): lib.lsl_get_desc.restype = ctypes.c_void_p lib.lsl_get_xml.restype = ctypes.c_char_p lib.lsl_create_outlet.restype = ctypes.c_void_p +lib.lsl_create_outlet_ex.restype = ctypes.c_void_p +lib.lsl_create_outlet_ex.argtypes = [ + ctypes.c_void_p, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, +] lib.lsl_create_inlet.restype = ctypes.c_void_p lib.lsl_get_fullinfo.restype = ctypes.c_void_p lib.lsl_get_info.restype = ctypes.c_void_p @@ -299,6 +306,13 @@ def find_liblsl_libraries(verbose=False): except Exception: # Available in liblsl >= 1.17.7; older versions don't expose these. pass +# noinspection PyBroadException +try: + lib.lsl_reset_uid.restype = ctypes.c_char_p + lib.lsl_reset_uid.argtypes = [ctypes.c_void_p] +except Exception: + # Available in liblsl >= 1.18.0; older versions don't expose this. + pass # int64 support on windows and 32bit OSes isn't there yet diff --git a/src/pylsl/outlet.py b/src/pylsl/outlet.py index e269645..0ec2561 100644 --- a/src/pylsl/outlet.py +++ b/src/pylsl/outlet.py @@ -20,7 +20,13 @@ class StreamOutlet: """ - def __init__(self, info: StreamInfo, chunk_size: int = 0, max_buffered: int = 360): + def __init__( + self, + info: StreamInfo, + chunk_size: int = 0, + max_buffered: int = 360, + transport_flags: int = 0, + ): """Establish a new stream outlet. This makes the stream discoverable. Keyword arguments: @@ -36,6 +42,12 @@ def __init__(self, info: StreamInfo, chunk_size: int = 0, max_buffered: int = 36 Note that, for high-bandwidth data, you will want to use a lower value here to avoid running out of RAM. (default 360) + transport_flags -- Optional bitwise-OR combination of transport option + flags (the transp_* constants). For example, + transp_sync_blocking enables synchronous (zero-copy) + pushes for high-bandwidth streams. The default, 0 + (transp_default), uses the standard asynchronous + transport. (default 0) """ @@ -71,7 +83,12 @@ def __init__(self, info: StreamInfo, chunk_size: int = 0, max_buffered: int = 36 new_desc_parent.remove_child(info.desc()) new_desc_parent.append_copy(old_desc) """ - self.obj = lib.lsl_create_outlet(info.obj, chunk_size, max_buffered) + if transport_flags: + self.obj = lib.lsl_create_outlet_ex( + info.obj, chunk_size, max_buffered, transport_flags + ) + else: + self.obj = lib.lsl_create_outlet(info.obj, chunk_size, max_buffered) self.obj = ctypes.c_void_p(self.obj) if not self.obj: raise RuntimeError("could not create stream outlet.") diff --git a/src/pylsl/util.py b/src/pylsl/util.py index 54a0305..be6bfd8 100644 --- a/src/pylsl/util.py +++ b/src/pylsl/util.py @@ -28,6 +28,18 @@ proc_none | proc_clocksync | proc_dejitter | proc_monotonize | proc_threadsafe ) +# Transport option flags for StreamOutlet (lsl_transport_options_t). Combine with +# bitwise OR and pass as the transport_flags argument when creating an outlet. +transp_default = 0 # Default transport behavior. +transp_bufsize_samples = 1 # Interpret max_buffered as a number of samples. +transp_bufsize_thousandths = 2 # Scale max_buffered by 0.001 (finer-grained buffering). +# Synchronous (blocking) zero-copy writes; push_sample/push_chunk write the caller's +# buffer directly to every consumer and block until handed to the OS. Reduces CPU for +# high-bandwidth streams at the cost of call latency. Notes: not compatible with the +# string format; push from a single thread only; the pushthrough flag is ignored. +# Requires liblsl >= 1.18.0. +transp_sync_blocking = 4 + def protocol_version(): """Protocol version. diff --git a/test/test_info.py b/test/test_info.py index c4271b4..92f6a94 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -1,5 +1,9 @@ +import pytest + import pylsl +_has_reset_uid = hasattr(pylsl.info.lib, "lsl_reset_uid") + def test_info_src_id(): name = "TestName" @@ -42,3 +46,26 @@ def test_info_src_id(): assert outlet_info.get_channel_labels() == [ f"Ch{chan_ix}" for chan_ix in range(1, chans + 1) ] + + +@pytest.mark.skipif( + not _has_reset_uid, reason="requires liblsl >= 1.18.0 (lsl_reset_uid)" +) +def test_reset_uid_generates_new_uid(): + info = pylsl.StreamInfo("T", "EEG", 4, 100, pylsl.cf_float32, source_id="src") + # A locally-constructed info has no UID until reset (or until bound to an outlet). + assert info.uid() == "" + new_uid = info.reset_uid() + assert new_uid != "" + assert info.uid() == new_uid + # A second reset yields a different value. + assert info.reset_uid() != new_uid + + +@pytest.mark.skipif( + _has_reset_uid, reason="only relevant when liblsl lacks lsl_reset_uid" +) +def test_reset_uid_raises_when_unavailable(): + info = pylsl.StreamInfo("T", "EEG", 4, 100, pylsl.cf_float32, source_id="src") + with pytest.raises(NotImplementedError): + info.reset_uid() diff --git a/test/test_outlet_transport.py b/test/test_outlet_transport.py new file mode 100644 index 0000000..ab33ea8 --- /dev/null +++ b/test/test_outlet_transport.py @@ -0,0 +1,54 @@ +"""Tests for StreamOutlet transport_flags, including synchronous (zero-copy) mode. + +Synchronous mode requires liblsl >= 1.18.0 (the transp_sync_blocking transport +flag). Tests that exercise it are skipped on older libraries. +""" + +import time + +import pytest + +import pylsl + +_has_sync = pylsl.library_version() >= 118 + + +def test_transport_constants_exposed(): + assert pylsl.transp_default == 0 + assert pylsl.transp_bufsize_samples == 1 + assert pylsl.transp_bufsize_thousandths == 2 + assert pylsl.transp_sync_blocking == 4 + + +def test_default_outlet_still_works(): + """transport_flags defaults to 0 (async); the standard path is unchanged.""" + info = pylsl.StreamInfo("TA", "EEG", 4, 100, pylsl.cf_float32, source_id="ta") + outlet = pylsl.StreamOutlet(info) + assert outlet.get_info().name() == "TA" + + +@pytest.mark.skipif( + not _has_sync, reason="requires liblsl >= 1.18.0 (transp_sync_blocking)" +) +def test_sync_outlet_round_trip(): + info = pylsl.StreamInfo("TS", "EEG", 4, 100, pylsl.cf_float32, source_id="ts") + outlet = pylsl.StreamOutlet(info, transport_flags=pylsl.transp_sync_blocking) + + streams = pylsl.resolve_byprop("name", "TS", timeout=10.0) + assert streams, "outlet was not resolved" + inlet = pylsl.StreamInlet(streams[0]) + inlet.open_stream(timeout=10.0) + time.sleep(0.5) # let the sync socket handoff complete before pushing + + sent = [[float(i)] * 4 for i in range(5)] + for sample in sent: + outlet.push_sample(sample) + + received = [] + for _ in range(len(sent)): + sample, _ts = inlet.pull_sample(timeout=5.0) + if sample is None: + break + received.append(sample) + + assert received == sent