Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish-to-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/pylsl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/pylsl/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 14 additions & 0 deletions src/pylsl/lib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions src/pylsl/outlet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)

"""

Expand Down Expand Up @@ -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.")
Expand Down
12 changes: 12 additions & 0 deletions src/pylsl/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions test/test_info.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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()
54 changes: 54 additions & 0 deletions test/test_outlet_transport.py
Original file line number Diff line number Diff line change
@@ -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
Loading