Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6999388
Changed disconnect wait to be dependent on bleak client reporting dis…
SonjaSt Sep 25, 2025
0802195
linter
SonjaSt Sep 25, 2025
3c89215
Merge pull request #410 from Mentalab-hub/fix-not-disconnecting
SonjaSt Sep 30, 2025
d95975e
Override n_chan with calibration parameter if available
salman2135 Oct 30, 2025
26d4e5d
Remove unused scale calculation in Trigger class
salman2135 Oct 30, 2025
eb4e74d
fix linter errors
salman2135 Oct 30, 2025
c8208c2
Merge pull request #411 from Mentalab-hub/update-trigger-in
salman2135 Oct 30, 2025
c5a558f
Merge branch 'APIS-1480-automate-imp-calibration' into update-trigger-in
salman2135 Oct 31, 2025
7fb4984
Add support for 32-channel impedance measurement
salman2135 Nov 6, 2025
ebdd82e
Added FW and HW dicts to retrieve ch count in settings manager, fixed…
SonjaSt Nov 28, 2025
1e82559
Removed 8 channel fallback, comments, print
SonjaSt Nov 28, 2025
44361ba
Merge pull request #412 from Mentalab-hub/fix-settings-manager
SonjaSt Nov 28, 2025
44988e1
Refine impedance data precision and remove debug prints
salman2135 Jan 13, 2026
7d57649
Handle AttributeError when stream interface is None
salman2135 Jan 15, 2026
610cdad
Remove debug print from CalibrationInfoPro32
salman2135 Jan 19, 2026
2cef33e
update for 16 and 32 channel impedance
salman2135 Jan 20, 2026
66c1e03
Fix impedance measurement calibration check
salman2135 Jan 20, 2026
3fb933f
Refactor impedance calibration flag handling
salman2135 Jan 21, 2026
2d0848e
Merge branch 'imp-calibration-32ch-pro' of github.com:Mentalab-hub/ex…
salman2135 Jan 21, 2026
4d3ecf5
Refactor channel count logic and calibration handling
salman2135 Jan 21, 2026
6783221
Refactor filter initialization in ImpedanceMeasurement
salman2135 Jan 21, 2026
c1c595b
Rename CALIBINFO__PRO_32 to CALIBINFO__PRO
salman2135 Jan 21, 2026
0d6fe32
Refactor calibration and impedance filter logic
salman2135 Jan 21, 2026
eb29624
Merge pull request #414 from Mentalab-hub/imp-calibration-32ch-pro
salman2135 Jan 23, 2026
f8b4c81
Merge pull request #416 from Mentalab-hub/APIS-1530-no-clean-disconne…
salman2135 Feb 10, 2026
6138726
handle calibration parser with fallback
salman2135 Feb 12, 2026
e258829
bumpversion minor
salman2135 Feb 12, 2026
d1a2abc
Merge pull request #417 from Mentalab-hub/APIS-1558-packet-parser-cra…
salman2135 Feb 12, 2026
5d393bc
Merge pull request #418 from Mentalab-hub/bumpversion-4.4.0
salman2135 Feb 12, 2026
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 README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
:target: https://pypi.org/project/explorepy


.. |commits-since| image:: https://img.shields.io/github/commits-since/Mentalab-hub/explorepy/v4.3.1.svg
.. |commits-since| image:: https://img.shields.io/github/commits-since/Mentalab-hub/explorepy/v4.4.0.svg
:alt: Commits since latest release
:target: https://github.com/Mentalab-hub/explorepy/compare/v4.3.1...master
:target: https://github.com/Mentalab-hub/explorepy/compare/v4.4.0...master


.. |wheel| image:: https://img.shields.io/pypi/wheel/explorepy.svg
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
year = '2018-2025'
author = 'Mentalab GmbH.'
copyright = '{0}, {1}'.format(year, author)
version = release = '4.3.1'
version = release = '4.4.0'
pygments_style = 'trac'
templates_path = ['.']
extlinks = {
Expand Down
4 changes: 2 additions & 2 deletions installer/windows/installer.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[Application]
name=MentaLab ExplorePy
version=4.3.1
version=4.4.0
entry_point=explorepy.cli:cli
console=true
icon=mentalab.ico
Expand All @@ -26,7 +26,7 @@ pypi_wheels =
decorator==5.1.1
distlib==0.3.7
eeglabio==0.0.2.post4
explorepy==4.3.1
explorepy==4.4.0
fonttools==4.42.1
idna==3.4
importlib-resources==6.0.1
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'

[project]
name = 'explorepy'
version = "4.3.1"
version = "4.4.0"
license = { text = "MIT" }
readme = { file = "README.rst", content-type = "text/markdown" }
authors = [
Expand Down
18 changes: 17 additions & 1 deletion src/explorepy/BLEClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from explorepy._exceptions import (
BleDisconnectionError,
BleDisconnectionFailedError,
DeviceNotFoundError,
UnexpectedConnectionError
)
Expand Down Expand Up @@ -200,7 +201,22 @@ def disconnect(self):
if self.notify_task:
self.notify_task.cancel()
self.read_event.set()
time.sleep(1)

min_time_to_wait = 0.5
max_time_to_wait = 5.
wait_start = time.time()
time_passed = 0.
if self.client is not None:
while self.client.is_connected:
time.sleep(0.1)
time_passed = time.time() - wait_start
if time_passed >= max_time_to_wait:
raise BleDisconnectionFailedError(f"Bleak client still not reporting disconnected after waiting "
f"{max_time_to_wait}.")
if time_passed < min_time_to_wait:
# Artificial delay to make the user think things are happening :)
time.sleep(min_time_to_wait - time_passed)

self.stop_read_loop()
self.ble_device = None
self.buffer = Queue()
Expand Down
2 changes: 1 addition & 1 deletion src/explorepy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@


__all__ = ["Explore", "command", "tools", "log_config"]
__version__ = '4.3.1'
__version__ = '4.4.0'

this = sys.modules[__name__]
# TODO appropriate library
Expand Down
7 changes: 7 additions & 0 deletions src/explorepy/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ class BleDisconnectionError(Exception):
pass


class BleDisconnectionFailedError(Exception):
"""
Exception for client fails to achieve disconnected state
"""
pass


class ExplorePyDeprecationError(Exception):
def __init__(self, message="Explorepy support for legacy devices is deprecated.\n"
"Please install explorepy 3.2.1 from Github or use the following command from Anaconda "
Expand Down
95 changes: 66 additions & 29 deletions src/explorepy/packet.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import numba as nb
import numpy as np

import explorepy.tools
from explorepy._exceptions import FletcherError


Expand Down Expand Up @@ -44,6 +43,7 @@ class PACKET_ID(IntEnum):
PUSHMARKER = 194
CALIBINFO = 195
CALIBINFO_USBC = 197
CALIBINFO_PRO = 196
TRIGGER_OUT = 177 # Trigger-out of Explore device
TRIGGER_IN = 178 # Trigger-in to Explore device
VERSION_INFO = 199
Expand Down Expand Up @@ -226,15 +226,19 @@ def int32_to_status(data):
}
return status

def calculate_impedance(self, imp_calib_info):
def calculate_impedance(self, imp_calib_info, index=None):
"""calculate impedance with the help of impedance calibration info

Args:
imp_calib_info (dict): dictionary of impedance calibration info including slope, offset and noise level

"""
scale = imp_calib_info["slope"]
offset = imp_calib_info["offset"]
if index is None:
scale = imp_calib_info["slope"]
offset = imp_calib_info["offset"]
else:
scale = imp_calib_info["slope"][index]
offset = imp_calib_info["offset"][index]
self.imp_data = np.round(
(self.get_ptp()
- imp_calib_info["noise_level"]) * scale / 1.0e6 - offset,
Expand Down Expand Up @@ -576,13 +580,6 @@ def __init__(self, timestamp, payload, time_offset=0):
super().__init__(timestamp, payload, time_offset)

def _convert(self, bin_data):
precise_ts = np.ndarray.item(
np.frombuffer(bin_data,
dtype=np.dtype(np.uint32).newbyteorder("<"),
count=1,
offset=0))
scale = 100000 if explorepy.tools.is_explore_pro_device() else 10000
self.timestamp = precise_ts / scale + self._time_offset
code = np.ndarray.item(
np.frombuffer(bin_data,
dtype=np.dtype(np.uint16).newbyteorder("<"),
Expand Down Expand Up @@ -710,35 +707,61 @@ def __str__(self):


class CalibrationInfoBase(Packet):
@abc.abstractmethod
def _convert(self, bin_data, offset_multiplier=0.001):
slope = np.frombuffer(bin_data,
dtype=np.dtype(np.uint16).newbyteorder("<"),
count=1,
offset=0).item()
self.slope = slope * 10.0
offset = np.frombuffer(bin_data,
dtype=np.dtype(np.uint16).newbyteorder("<"),
count=1,
offset=2).item()
self.offset = offset * offset_multiplier
"""Base class for calibration packets"""

channels = 4
offset_multiplier = 0.001

def __init__(self, timestamp, payload, time_offset=0):
# Must exist before Packet.__init__ calls _convert()
self.slope = []
self.offset = []
super().__init__(timestamp, payload, time_offset)

def _convert(self, bin_data):
dtype_u16 = np.dtype("<u2")
calib_pair_count = len(bin_data) // 4
for i in range(self.channels):
if calib_pair_count <= i:
# Copy first value
self.slope.append(self.slope[0])
self.offset.append(self.offset[0])
continue
base = i * 4

slope = np.frombuffer(
bin_data,
dtype=dtype_u16,
count=1,
offset=base
).item()
self.slope.append(slope * 10.0)

offset = np.frombuffer(
bin_data,
dtype=dtype_u16,
count=1,
offset=base + 2
).item()
self.offset.append(offset * self.offset_multiplier)

def get_info(self):
"""Get calibration info"""
return {"slope": self.slope, "offset": self.offset}

def __str__(self):
return "calibration info: slope = " + str(self.slope) + "\toffset = " + str(self.offset)
return f"calibration info: slope = {self.slope}\toffset = {self.offset}"


class CalibrationInfo(CalibrationInfoBase):
def _convert(self, bin_data):
super()._convert(bin_data, offset_multiplier=0.001)
offset_multiplier = 0.001


class CalibrationInfo_USBC(CalibrationInfoBase):
def _convert(self, bin_data):
super()._convert(bin_data, offset_multiplier=0.01)
offset_multiplier = 0.01


class CalibrationInfoPro(CalibrationInfoBase):
offset_multiplier = 0.01


class BleImpedancePacket(EEG98_USBC):
Expand All @@ -759,6 +782,19 @@ def populate_packet_with_data(self, ble_packet_list):
data_array = np.concatenate((data_array, data), axis=1)
self.data = data_array

def resize_packet(self, full_data, index):
self.data = full_data[index * 8: index * 8 + 8, :]

def populate_data_1d(self, ble_packet_list):
data_array = None
for i in range(len(ble_packet_list)):
_, data = ble_packet_list[i].get_data()
if data_array is None:
data_array = data
else:
data_array = np.concatenate((data_array, data), axis=0)
self.data = data_array


class VersionInfoPacket(Packet):
def __init__(self, timestamp, payload, time_offset=0):
Expand Down Expand Up @@ -800,6 +836,7 @@ def __str__(self):
PACKET_ID.CMDSTAT: CommandStatus,
PACKET_ID.CALIBINFO: CalibrationInfo,
PACKET_ID.CALIBINFO_USBC: CalibrationInfo_USBC,
PACKET_ID.CALIBINFO_PRO: CalibrationInfoPro,
PACKET_ID.PUSHMARKER: PushButtonMarker,
PACKET_ID.TRIGGER_IN: TriggerIn,
PACKET_ID.TRIGGER_OUT: TriggerOut,
Expand Down
4 changes: 4 additions & 0 deletions src/explorepy/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ def _stream_loop(self):
except EOFError:
logger.info('End of file')
self.stop_streaming()
except AttributeError:
if self.stream_interface is None:
# device already disconnected
pass
except Exception as error:
logger.critical('Unexpected error: ', error)
self.stop_streaming()
Expand Down
71 changes: 27 additions & 44 deletions src/explorepy/settings_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@


class SettingsManager:
_fw_ch_count = {"7": 8, "8": 16, "9": 32}
_board_ch_count = {"PCB_304_801_XXX": 32, "PCB_305_801_XXX": 16, "PCB_303_801E_XX": 8,
"PCB_304_801p2_X": 32, "PCB_304_891p2_X": 16}

def __init__(self, name):
self.settings_dict = None

Expand All @@ -28,6 +32,7 @@ def __init__(self, name):
pass
self.hardware_channel_mask_key = "hardware_mask"
self.software_channel_mask_key = "software_mask"
self.firmware_version_key = "firmware_version"
self.adc_mask_key = "adc_mask"
self.channel_name_key = "channel_name"
self.channel_count_key = "channel_count"
Expand Down Expand Up @@ -105,52 +110,30 @@ def update_device_settings(self, device_info_dict_update):
self.load_current_settings()
for key, value in device_info_dict_update.items():
self.settings_dict[key] = value
if "board_id" in device_info_dict_update:
if self.settings_dict["board_id"] == "PCB_304_801_XXX":
self.settings_dict[self.channel_count_key] = 32
self.settings_dict[self.hardware_channel_mask_key] = [1 for _ in range(32)]
if self.software_channel_mask_key not in self.settings_dict:
hardware_adc = self.settings_dict.get(self.hardware_channel_mask_key)
self.settings_dict[self.software_channel_mask_key] = hardware_adc
self.settings_dict[self.adc_mask_key] = self.settings_dict.get(self.software_channel_mask_key)
if "board_id" in device_info_dict_update:
if self.settings_dict["board_id"] == "PCB_305_801_XXX":
self.settings_dict[self.channel_count_key] = 16
self.settings_dict[self.hardware_channel_mask_key] = [1 for _ in range(16)]
if self.software_channel_mask_key not in self.settings_dict:
hardware_adc = self.settings_dict.get(self.hardware_channel_mask_key)
self.settings_dict[self.software_channel_mask_key] = hardware_adc
self.settings_dict[self.adc_mask_key] = self.settings_dict.get(self.software_channel_mask_key)
if "board_id" in device_info_dict_update:
# 8 channel BLE board
if self.settings_dict["board_id"] == "PCB_303_801E_XXX":
self.settings_dict[self.channel_count_key] = 8
self.settings_dict[self.hardware_channel_mask_key] = [1 for _ in range(8)]
if self.software_channel_mask_key not in self.settings_dict:
hardware_adc = self.settings_dict.get(self.hardware_channel_mask_key)
self.settings_dict[self.software_channel_mask_key] = hardware_adc
self.settings_dict[self.adc_mask_key] = self.settings_dict.get(self.software_channel_mask_key)
if "board_id" in device_info_dict_update:
# 32 channel BLE board
if self.settings_dict["board_id"] == "PCB_304_801p2_X":
self.settings_dict[self.channel_count_key] = 32
self.settings_dict[self.hardware_channel_mask_key] = [1 for _ in range(32)]
if self.software_channel_mask_key not in self.settings_dict:
hardware_adc = self.settings_dict.get(self.hardware_channel_mask_key)
self.settings_dict[self.software_channel_mask_key] = hardware_adc
self.settings_dict[self.adc_mask_key] = self.settings_dict.get(self.software_channel_mask_key)
if "board_id" in device_info_dict_update:
# 32 channel BLE board
if self.settings_dict["board_id"] == "PCB_304_891p2_X":
self.settings_dict[self.channel_count_key] = 16
self.settings_dict[self.hardware_channel_mask_key] = [1 for _ in range(16)]
if self.software_channel_mask_key not in self.settings_dict:
hardware_adc = self.settings_dict.get(self.hardware_channel_mask_key)
self.settings_dict[self.software_channel_mask_key] = hardware_adc
self.settings_dict[self.adc_mask_key] = self.settings_dict.get(self.software_channel_mask_key)
ch_count = -1
if self.firmware_version_key in device_info_dict_update:
fw = device_info_dict_update[self.firmware_version_key]
major = fw.split(".")[0]
if major in self._fw_ch_count:
ch_count = self._fw_ch_count[major]
if ch_count == -1:
logger.warn("Could not retrieve channel count from firmware version, attempting to get channel count from "
"board ID...")
# fallback to PCB ID
if self.board_id_key in device_info_dict_update:
for key in self._board_ch_count:
if self.settings_dict["board_id"] == key:
ch_count = self._board_ch_count[key]
if ch_count != -1:
self.settings_dict[self.channel_count_key] = ch_count
self.settings_dict[self.hardware_channel_mask_key] = [1 for _ in range(ch_count)]
if self.software_channel_mask_key not in self.settings_dict:
hardware_adc = self.settings_dict.get(self.hardware_channel_mask_key)
self.settings_dict[self.software_channel_mask_key] = hardware_adc
self.settings_dict[self.adc_mask_key] = self.settings_dict.get(self.software_channel_mask_key)

if self.channel_count_key not in self.settings_dict:
self.settings_dict[self.channel_count_key] = 8 if sum(self.settings_dict["adc_mask"]) > 4 else 4
raise KeyError("Channel count could not be set from firmware or hardware version!")
if self.channel_name_key not in self.settings_dict:
self.settings_dict[self.channel_name_key] = [f'ch{i + 1}' for i in
range(self.settings_dict[self.channel_count_key])]
Expand Down
Loading
Loading