From d387e709275236e06af8fcf8b61f7d07976d07da Mon Sep 17 00:00:00 2001 From: Fede654 Date: Wed, 29 Oct 2025 17:52:34 -0300 Subject: [PATCH 1/3] Fix two critical bugs in muselsl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Bug 1: Bluetoothctl EOF handling (stream.py) - **Issue**: `muselsl list` fails on systems where bluetoothctl exits with EOF - **Fix**: Handle both EOF and TIMEOUT as normal scan completion conditions - **Impact**: Enables device discovery on affected systems (Debian 12, Python 3.13+) The original code treated pexpect.EOF as an error condition, but bluetoothctl may exit cleanly after starting the scan, which is a normal behavior. The scan continues at the Bluetooth stack level even after the process exits. ## Bug 2: Record filename directory creation (record.py) - **Issue**: `muselsl record -f file.csv` fails for simple filenames - **Fix**: Skip directory creation when dirname() returns empty string - **Impact**: Allows recording to current directory with simple filenames When os.path.dirname('file.csv') returns '', attempting os.makedirs('') raises FileNotFoundError. Added check to skip directory creation for empty dirname, preserving original behavior for paths with directories. ## Testing - Tested on Debian 12, Python 3.13 - Verified device discovery with `muselsl list` - Verified recording with simple filenames and directory paths - No regression in existing functionality Fixes issues related to #208, #191 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- muselsl/record.py | 4 ++-- muselsl/stream.py | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/muselsl/record.py b/muselsl/record.py index f312aab..1cab34b 100644 --- a/muselsl/record.py +++ b/muselsl/record.py @@ -149,7 +149,7 @@ def _save( data = pd.DataFrame(data=res, columns=["timestamps"] + ch_names) directory = os.path.dirname(filename) - if not os.path.exists(directory): + if directory and not os.path.exists(directory): os.makedirs(directory) if inlet_marker and markers: @@ -263,7 +263,7 @@ def save_eeg(new_samples, new_timestamps): recording['timestamps'] = timestamps directory = os.path.dirname(filename) - if not os.path.exists(directory): + if directory and not os.path.exists(directory): os.makedirs(directory) recording.to_csv(filename, float_format='%.3f') diff --git a/muselsl/stream.py b/muselsl/stream.py index efdaedb..8ae29cf 100644 --- a/muselsl/stream.py +++ b/muselsl/stream.py @@ -90,13 +90,20 @@ def _list_muses_bluetoothctl(timeout, verbose=False): scan = pexpect.spawn('bluetoothctl scan on') try: scan.expect('foooooo', timeout=timeout) - except pexpect.EOF: - before_eof = scan.before.decode('utf-8', 'replace') - msg = f'Unexpected error when scanning: {before_eof}' - raise ValueError(msg) - except pexpect.TIMEOUT: + except (pexpect.EOF, pexpect.TIMEOUT): + # Both EOF and TIMEOUT are expected - the scan completed normally if verbose: - print(scan.before.decode('utf-8', 'replace').split('\r\n')) + try: + output = scan.before.decode('utf-8', 'replace') + print(output.split('\r\n')) + except: + pass + + # Terminate the scan process if still running + try: + scan.terminate(force=True) + except: + pass # List devices using bluetoothctl list_devices_cmd = ['bluetoothctl', 'devices'] From fa5b38d4980dfc21502e850965018df344241edc Mon Sep 17 00:00:00 2001 From: Fede654 Date: Sat, 1 Nov 2025 21:06:40 -0300 Subject: [PATCH 2/3] Update pylsl requirement to >=1.16.2 for LSL compatibility - Remove Linux-specific pinning to pylsl==1.10.5 - Allow modern pylsl versions (>=1.16.2) - Fixes version conflicts with projects using newer LSL features - Consistent behavior across all platforms --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index bb84eb3..84594f3 100644 --- a/setup.py +++ b/setup.py @@ -47,8 +47,8 @@ def copy_docs(): "numpy", "seaborn", "pexpect", - ] + - (["pylsl==1.10.5"] if os.sys.platform.startswith("linux") else ["pylsl"]), + "pylsl>=1.16.2", # Updated for compatibility with modern LSL features + ], extras_require={"Viewer V2": ["mne", "vispy"]}, classifiers=[ # How mature is this project? Common values are From 7d038ef0f629d6c0fe5eb0952edc54822d423944 Mon Sep 17 00:00:00 2001 From: Fede654 Date: Sun, 2 Nov 2025 16:05:51 -0300 Subject: [PATCH 3/3] Use --name parameter for LSL stream names to support multiple devices Previously, LSL stream names were hardcoded to 'Muse' regardless of the --name CLI parameter. This prevented running multiple Muse devices simultaneously, as they would all publish to the same stream name. Changes: - Use --name parameter value for LSL stream name if provided - Apply to all stream types: EEG, PPG, ACC, GYRO - Fallback to 'Muse' if --name not specified (backward compatibility) - Add debug output showing the stream name being used This enables multi-device setups like: muselsl stream --address XX:XX:XX:XX:XX:01 --name Muse_1 muselsl stream --address XX:XX:XX:XX:XX:02 --name Muse_2 Each device will now publish to its own uniquely named LSL stream. --- muselsl/stream.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/muselsl/stream.py b/muselsl/stream.py index 8ae29cf..58d316a 100644 --- a/muselsl/stream.py +++ b/muselsl/stream.py @@ -161,8 +161,14 @@ def stream( address = found_muse['address'] name = found_muse['name'] + # Determine LSL stream name: use --name parameter if provided, otherwise default to 'Muse' + # This allows multiple devices to have unique stream names (e.g., "Muse_1", "Muse_2") + stream_name = name if name else 'Muse' + + print(f"Creating LSL streams with name: '{stream_name}'") + if not eeg_disabled: - eeg_info = StreamInfo('Muse', 'EEG', MUSE_NB_EEG_CHANNELS, MUSE_SAMPLING_EEG_RATE, 'float32', + eeg_info = StreamInfo(stream_name, 'EEG', MUSE_NB_EEG_CHANNELS, MUSE_SAMPLING_EEG_RATE, 'float32', 'Muse%s' % address) eeg_info.desc().append_child_value("manufacturer", "Muse") eeg_channels = eeg_info.desc().append_child("channels") @@ -176,7 +182,7 @@ def stream( eeg_outlet = StreamOutlet(eeg_info, LSL_EEG_CHUNK) if ppg_enabled: - ppg_info = StreamInfo('Muse', 'PPG', MUSE_NB_PPG_CHANNELS, MUSE_SAMPLING_PPG_RATE, + ppg_info = StreamInfo(stream_name, 'PPG', MUSE_NB_PPG_CHANNELS, MUSE_SAMPLING_PPG_RATE, 'float32', 'Muse%s' % address) ppg_info.desc().append_child_value("manufacturer", "Muse") ppg_channels = ppg_info.desc().append_child("channels") @@ -190,7 +196,7 @@ def stream( ppg_outlet = StreamOutlet(ppg_info, LSL_PPG_CHUNK) if acc_enabled: - acc_info = StreamInfo('Muse', 'ACC', MUSE_NB_ACC_CHANNELS, MUSE_SAMPLING_ACC_RATE, + acc_info = StreamInfo(stream_name, 'ACC', MUSE_NB_ACC_CHANNELS, MUSE_SAMPLING_ACC_RATE, 'float32', 'Muse%s' % address) acc_info.desc().append_child_value("manufacturer", "Muse") acc_channels = acc_info.desc().append_child("channels") @@ -204,7 +210,7 @@ def stream( acc_outlet = StreamOutlet(acc_info, LSL_ACC_CHUNK) if gyro_enabled: - gyro_info = StreamInfo('Muse', 'GYRO', MUSE_NB_GYRO_CHANNELS, MUSE_SAMPLING_GYRO_RATE, + gyro_info = StreamInfo(stream_name, 'GYRO', MUSE_NB_GYRO_CHANNELS, MUSE_SAMPLING_GYRO_RATE, 'float32', 'Muse%s' % address) gyro_info.desc().append_child_value("manufacturer", "Muse") gyro_channels = gyro_info.desc().append_child("channels")