From cdda6bf08f576928d0c89b4e0995bbdb9e9169db Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Tue, 5 May 2026 14:06:41 +0200 Subject: [PATCH 01/14] Add get_status / get_environment to ByonoyBase Wraps two device-info reports decoded from Byonoy's C library headers: REP_STATUS_IN (0x0300, status_in_t) and REP_ENVIRONMENT_IN (0x0310, environment_in_t). Each is a request with empty payload that the device echoes back on the same report id with a fixed-layout struct. ByonoyStatus exposes is_initialized, slot state, error_code, uptime, in-progress flag, boot_completed. ByonoyEnvironment exposes temperature, humidity (0..1) and three-axis acceleration. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/backend.py | 52 +++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 726e7509568..f508a19b90e 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -4,7 +4,8 @@ import threading import time from abc import ABCMeta -from typing import Optional +from dataclasses import dataclass +from typing import Optional, Tuple from pylabrobot.capabilities.capability import BackendParams from pylabrobot.device import Driver @@ -19,6 +20,30 @@ class ByonoyDevice(enum.Enum): LUMINESCENCE_96 = enum.auto() +class ByonoySlotState(enum.IntEnum): + UNKNOWN = 0 + EMPTY = 1 + OCCUPIED = 2 + UNDETERMINED = 3 + + +@dataclass +class ByonoyStatus: + is_initialized: bool + slot_state: ByonoySlotState + error_code: int + uptime_s: int + is_measuring: bool + boot_completed: bool + + +@dataclass +class ByonoyEnvironment: + temperature_c: float + humidity: float # 0..1 + acceleration_xyz: Tuple[int, int, int] + + class ByonoyBase(Driver, metaclass=ABCMeta): """Shared HID communication logic for Byonoy plate readers.""" @@ -107,3 +132,28 @@ def _start_background_pings(self) -> None: def _stop_background_pings(self) -> None: self._sending_pings = False + + async def get_status(self) -> ByonoyStatus: + """Read REP_STATUS_IN (0x0300): init/slot/error/uptime/measuring/boot.""" + response = await self.send_command(report_id=0x0300, payload=b"\x00" * 60) + assert response is not None + r = Reader(response[2:]) + return ByonoyStatus( + is_initialized=r.u8() != 0, + slot_state=ByonoySlotState(r.u8()), + error_code=r.u8(), + uptime_s=r.u32(), + is_measuring=r.u8() != 0, + boot_completed=r.u8() != 0, + ) + + async def get_environment(self) -> ByonoyEnvironment: + """Read REP_ENVIRONMENT_IN (0x0310): temperature, humidity, acceleration.""" + response = await self.send_command(report_id=0x0310, payload=b"\x00" * 60) + assert response is not None + r = Reader(response[2:]) + return ByonoyEnvironment( + temperature_c=r.i16() / 100.0, + humidity=r.i16() / 1000.0, + acceleration_xyz=(r.i16(), r.i16(), r.i16()), + ) From a612ff31113dff642e17a53bfa9f764addc1c66e Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Tue, 5 May 2026 14:12:53 +0200 Subject: [PATCH 02/14] get_versions + acceleration in g MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds get_versions() reading REP_VERSIONS_IN (0x0080, versions_in_t): system/STM/ESP/bootloader versions plus is_production helper that flags when both dev counters are zero (matches DEV_VERSION_IS_PRODUCTION sentinel from byonoyusbhid.h). Renames ByonoyEnvironment.acceleration_xyz → acceleration_g and divides by 16384 LSB/g (14-bit signed accelerometer at ±2 g full scale) so the dataclass exposes physical units instead of raw counts. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/backend.py | 46 ++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index f508a19b90e..6a1825f9501 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -41,7 +41,28 @@ class ByonoyStatus: class ByonoyEnvironment: temperature_c: float humidity: float # 0..1 - acceleration_xyz: Tuple[int, int, int] + acceleration_g: Tuple[float, float, float] + + +@dataclass +class ByonoyVersions: + system_version: int + stm_version: int + stm_dev_version: int + esp_version: int + esp_dev_version: int + stm_bootloader_version: int + + @property + def system_version_known(self) -> bool: + return self.system_version != 0 + + @property + def is_production(self) -> bool: + return self.stm_dev_version == 0 and self.esp_dev_version == 0 + + +_ACCEL_LSB_PER_G = 16384.0 # 14-bit signed @ ±2 g full scale class ByonoyBase(Driver, metaclass=ABCMeta): @@ -152,8 +173,25 @@ async def get_environment(self) -> ByonoyEnvironment: response = await self.send_command(report_id=0x0310, payload=b"\x00" * 60) assert response is not None r = Reader(response[2:]) + temp_c = r.i16() / 100.0 + humidity = r.i16() / 1000.0 + ax, ay, az = r.i16(), r.i16(), r.i16() return ByonoyEnvironment( - temperature_c=r.i16() / 100.0, - humidity=r.i16() / 1000.0, - acceleration_xyz=(r.i16(), r.i16(), r.i16()), + temperature_c=temp_c, + humidity=humidity, + acceleration_g=(ax / _ACCEL_LSB_PER_G, ay / _ACCEL_LSB_PER_G, az / _ACCEL_LSB_PER_G), + ) + + async def get_versions(self) -> ByonoyVersions: + """Read REP_VERSIONS_IN (0x0080): system / STM / ESP / bootloader versions.""" + response = await self.send_command(report_id=0x0080, payload=b"\x00" * 60) + assert response is not None + r = Reader(response[2:]) + return ByonoyVersions( + system_version=r.u32(), + stm_version=r.u32(), + stm_dev_version=r.u32(), + esp_version=r.u32(), + esp_dev_version=r.u32(), + stm_bootloader_version=r.u32(), ) From a1793ce4c1557f534bb2725d151753f9a8ce1a30 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Tue, 5 May 2026 14:19:21 +0200 Subject: [PATCH 03/14] Fix routing_info for query reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_status / get_environment / get_versions were sending the default routing_info=\x00\x00 (fire-and-forget) so the device dropped the requests. Match the existing v1b1 pattern in absorbance_96.py (request_available_absorbance_wavelengths uses \x80\x40) — that's the "this is a request, please reply" routing tag in Byonoy's HID frame. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/backend.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 6a1825f9501..9ea7f224825 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -156,7 +156,9 @@ def _stop_background_pings(self) -> None: async def get_status(self) -> ByonoyStatus: """Read REP_STATUS_IN (0x0300): init/slot/error/uptime/measuring/boot.""" - response = await self.send_command(report_id=0x0300, payload=b"\x00" * 60) + response = await self.send_command( + report_id=0x0300, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) assert response is not None r = Reader(response[2:]) return ByonoyStatus( @@ -170,7 +172,9 @@ async def get_status(self) -> ByonoyStatus: async def get_environment(self) -> ByonoyEnvironment: """Read REP_ENVIRONMENT_IN (0x0310): temperature, humidity, acceleration.""" - response = await self.send_command(report_id=0x0310, payload=b"\x00" * 60) + response = await self.send_command( + report_id=0x0310, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) assert response is not None r = Reader(response[2:]) temp_c = r.i16() / 100.0 @@ -184,7 +188,9 @@ async def get_environment(self) -> ByonoyEnvironment: async def get_versions(self) -> ByonoyVersions: """Read REP_VERSIONS_IN (0x0080): system / STM / ESP / bootloader versions.""" - response = await self.send_command(report_id=0x0080, payload=b"\x00" * 60) + response = await self.send_command( + report_id=0x0080, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) assert response is not None r = Reader(response[2:]) return ByonoyVersions( From 5a7d8d85aaf93e23cdd3577d2c57d1a69c8ccd69 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Tue, 5 May 2026 14:22:44 +0200 Subject: [PATCH 04/14] get_supported_reports + get_api_version Adds two device-info queries to ByonoyBase: - get_api_version() reads REP_API_VERSION_IN (0x0050, single u32) - get_supported_reports() reads REP_SUPPORTED_REPORTS_IN (0x0010, multi-chunk seq/seq_len reply with up to 29 u16 ids per chunk) The supported-reports list lets callers feature-gate optional queries instead of waiting for a 120 s timeout when a model doesn't carry e.g. slot status (suspected reason Lum96 returned slot_state=UNKNOWN). Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/backend.py | 49 +++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 9ea7f224825..df20a714401 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -5,7 +5,7 @@ import time from abc import ABCMeta from dataclasses import dataclass -from typing import Optional, Tuple +from typing import List, Optional, Tuple from pylabrobot.capabilities.capability import BackendParams from pylabrobot.device import Driver @@ -62,6 +62,11 @@ def is_production(self) -> bool: return self.stm_dev_version == 0 and self.esp_dev_version == 0 +@dataclass +class ByonoyApiVersion: + version_no: int + + _ACCEL_LSB_PER_G = 16384.0 # 14-bit signed @ ±2 g full scale @@ -186,6 +191,48 @@ async def get_environment(self) -> ByonoyEnvironment: acceleration_g=(ax / _ACCEL_LSB_PER_G, ay / _ACCEL_LSB_PER_G, az / _ACCEL_LSB_PER_G), ) + async def get_api_version(self) -> ByonoyApiVersion: + """Read REP_API_VERSION_IN (0x0050): a single u32.""" + response = await self.send_command( + report_id=0x0050, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) + assert response is not None + r = Reader(response[2:]) + return ByonoyApiVersion(version_no=r.u32()) + + async def get_supported_reports(self) -> List[int]: + """Read REP_SUPPORTED_REPORTS_IN (0x0010): list of report IDs the device supports. + + Reply is delivered in seq/seq_len chunks of up to 29 u16 ids; zero-valued + entries are padding. Returns the deduplicated, ordered union. + """ + cmd = self._assemble_command(report_id=0x0010, payload=b"\x00" * 60, routing_info=b"\x80\x40") + await self.io.write(cmd) + + seen: List[int] = [] + t0 = time.time() + while True: + if time.time() - t0 > 30: + raise TimeoutError("Timed out reading supported reports.") + chunk = await self.io.read(64, timeout=10) + if len(chunk) == 0: + continue + r = Reader(chunk) + if r.u16() != 0x0010: + continue + seq = r.u8() + seq_len = r.u8() + ids = [r.u16() for _ in range(29)] + seen.extend(i for i in ids if i != 0) + if seq == seq_len - 1: + break + # Preserve order, drop dupes + out: List[int] = [] + for i in seen: + if i not in out: + out.append(i) + return out + async def get_versions(self) -> ByonoyVersions: """Read REP_VERSIONS_IN (0x0080): system / STM / ESP / bootloader versions.""" response = await self.send_command( From 6567ec49316386976a54320711091252b859457b Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Tue, 5 May 2026 14:24:22 +0200 Subject: [PATCH 05/14] LuminescenceParams: integration mode + per-well selection Replaces the single integration_time field with the full configuration the firmware accepts: - mode: Lum96IntegrationMode (RAPID 100ms / SENSITIVE 2s / ULTRA_SENSITIVE 20s / CUSTOM); preset durations match byonoy_device_library hidmeasurements.cpp toIntegrationTime(). - integration_time: when set, forces CUSTOM mode (preserves the legacy call shape used by legacy/plate_reading/byonoy adapter). - selected_wells: optional 96-bool list in plate row-major order; if None and `wells` is a strict subset of the plate, the bitmask is derived from `wells` instead of hardcoding all 96. The lum_trigger_measurement_out_t payload (i32 integration_time_us + 12-byte well bitmask + is_reference + flags) is now built from these inputs instead of the previous \xff*12 + u8(0) + u8(0) hardcode. The mode enum, preset table, and encode_well_bitmask helper live in backend.py so Lum384 / Flu96 can reuse them later. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/__init__.py | 1 + pylabrobot/byonoy/backend.py | 27 ++++++++++++++ pylabrobot/byonoy/luminescence_96.py | 54 +++++++++++++++++++++++----- 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/pylabrobot/byonoy/__init__.py b/pylabrobot/byonoy/__init__.py index c9289dff529..5ccebd7f6b3 100644 --- a/pylabrobot/byonoy/__init__.py +++ b/pylabrobot/byonoy/__init__.py @@ -1,3 +1,4 @@ +from .backend import Lum96IntegrationMode from .absorbance_96 import ( ByonoyAbsorbance96, ByonoyAbsorbance96Backend, diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index df20a714401..5459ced5a99 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -27,6 +27,33 @@ class ByonoySlotState(enum.IntEnum): UNDETERMINED = 3 +class Lum96IntegrationMode(enum.Enum): + RAPID = "rapid" + SENSITIVE = "sensitive" + ULTRA_SENSITIVE = "ultra_sensitive" + CUSTOM = "custom" + + +# Preset integration times (matches byonoy_device_library: hidmeasurements.cpp) +LUM96_PRESET_S = { + Lum96IntegrationMode.RAPID: 0.1, + Lum96IntegrationMode.SENSITIVE: 2.0, + Lum96IntegrationMode.ULTRA_SENSITIVE: 20.0, +} + + +def encode_well_bitmask(selected: List[bool], n: int = 96) -> bytes: + """Pack a length-n bool list into a little-endian bitmask, LSB-first within each byte.""" + if len(selected) != n: + raise ValueError(f"expected {n} bools, got {len(selected)}") + nbytes = (n + 7) // 8 + out = bytearray(nbytes) + for i, b in enumerate(selected): + if b: + out[i // 8] |= 1 << (i % 8) + return bytes(out) + + @dataclass class ByonoyStatus: is_initialized: bool diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index 6abf9e68c9d..4fe2720dd03 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -3,7 +3,13 @@ from dataclasses import dataclass from typing import List, Optional, Tuple -from pylabrobot.byonoy.backend import ByonoyBase, ByonoyDevice +from pylabrobot.byonoy.backend import ( + LUM96_PRESET_S, + ByonoyBase, + ByonoyDevice, + Lum96IntegrationMode, + encode_well_bitmask, +) from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.luminescence import ( Luminescence, @@ -38,10 +44,18 @@ class LuminescenceParams(BackendParams): """Byonoy Luminescence 96 parameters for luminescence reads. Args: - integration_time: Integration time in seconds. Default 2. + mode: One of RAPID (100 ms), SENSITIVE (2 s, default), ULTRA_SENSITIVE + (20 s), or CUSTOM. Presets match the byonoy_device_library mapping. + integration_time: Integration time in seconds. If set, forces CUSTOM + mode regardless of `mode`. Required when `mode == CUSTOM`. + selected_wells: Optional 96-bool mask in plate row-major order (A1..H12). + If None, the wells passed to `read_luminescence` decide which wells + are sampled (defaulting to all 96). """ - integration_time: float = 2 + mode: Lum96IntegrationMode = Lum96IntegrationMode.SENSITIVE + integration_time: Optional[float] = None + selected_wells: Optional[List[bool]] = None async def read_luminescence( self, @@ -61,14 +75,33 @@ async def read_luminescence( if not isinstance(backend_params, self.LuminescenceParams): backend_params = ByonoyLuminescence96Backend.LuminescenceParams() - integration_time = backend_params.integration_time + # Resolve mode + integration time + if backend_params.integration_time is not None: + mode = Lum96IntegrationMode.CUSTOM + integration_time = backend_params.integration_time + elif backend_params.mode == Lum96IntegrationMode.CUSTOM: + raise ValueError("CUSTOM mode requires integration_time to be set.") + else: + mode = backend_params.mode + integration_time = LUM96_PRESET_S[mode] + + # Resolve well mask + if backend_params.selected_wells is not None: + mask_bools = backend_params.selected_wells + else: + all_items = plate.get_all_items() + well_set = set(id(w) for w in wells) + mask_bools = [id(w) in well_set for w in all_items] + + well_mask = encode_well_bitmask(mask_bools, n=96) logger.info( - "[Byonoy L96 pid=0x%04X] reading luminescence: plate='%s', integration_time=%.1fs, wells=%d/%d", + "[Byonoy L96 pid=0x%04X] reading luminescence: plate='%s', mode=%s, " + "integration_time=%.3fs, wells=%d/96", self.io.pid, plate.name, + mode.name, integration_time, - len(wells), - plate.num_items, + sum(mask_bools), ) await self.send_command( @@ -85,7 +118,12 @@ async def read_luminescence( ) payload3 = ( - Writer().i32(int(integration_time * 1000 * 1000)).raw_bytes(b"\xff" * 12).u8(0).u8(0).finish() + Writer() + .i32(int(integration_time * 1_000_000)) + .raw_bytes(well_mask) + .u8(0) # is_reference_measurement + .u8(0) # flags + .finish() ) await self.send_command( report_id=0x0340, From 342ad439d9a90daa70857ea377fcaf496dd72545 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Tue, 5 May 2026 15:02:30 +0200 Subject: [PATCH 06/14] get_device_info, cancel, LED bar control Three more wrappers around reports the device advertised in get_supported_reports(): get_device_info() reads the named-data-fields protocol (REP_DEVICE_DATA_ READ_IN, 0x0200) for DD_DEVICE_ID / DD_DEVICE_NAME / DD_DEVICE_MANUFAC- TURER / DD_SERIAL_NO / DD_FIRMWARE_VERSION / DD_REF_NUMBER, returning a ByonoyDeviceInfo dataclass. The lower-level read_data_field() decodes the union by the type bits (string/int/float/bool/bytes) and warns if HAS_MORE_DATA is ever set (the identity strings comfortably fit in one 52-byte payload, so single-chunk read is enough for now). cancel(report_id=0x0340) sends REP_ABORT_REPORT_OUT (0x0060) with the trigger report id to abort, so a user can interrupt a long ULTRA_SENSI- TIVE read mid-integration. set_led_colours() and set_led_effect() drive the 20-LED front bar via REP_LED_BAR_COLOURS_OUT (0x0350) and REP_LED_BAR_EFFECTS_OUT (0x0351). LedEffect mirrors the firmware enum (SOLID/PROGRESS/CYLON/RAINBOW/ BLINKING/BREATHING). Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/__init__.py | 9 ++- pylabrobot/byonoy/backend.py | 133 ++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/pylabrobot/byonoy/__init__.py b/pylabrobot/byonoy/__init__.py index 5ccebd7f6b3..80cb5ccef31 100644 --- a/pylabrobot/byonoy/__init__.py +++ b/pylabrobot/byonoy/__init__.py @@ -1,4 +1,11 @@ -from .backend import Lum96IntegrationMode +from .backend import ( + ByonoyDeviceInfo, + ByonoyEnvironment, + ByonoyStatus, + ByonoyVersions, + LedEffect, + Lum96IntegrationMode, +) from .absorbance_96 import ( ByonoyAbsorbance96, ByonoyAbsorbance96Backend, diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 5459ced5a99..2df7f22b3d0 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -94,6 +94,42 @@ class ByonoyApiVersion: version_no: int +@dataclass +class ByonoyDeviceInfo: + device_id: str + device_name: str + manufacturer: str + serial_no: str + firmware_version: str + ref_number: str + + +# device_data_field_id (byonoyusbhid.h) +_DD_DEVICE_ID = 0 +_DD_DEVICE_NAME = 1 +_DD_DEVICE_MANUFACTURER = 2 +_DD_SERIAL_NO = 3 +_DD_FIRMWARE_VERSION = 4 +_DD_REF_NUMBER = 8 + +# device_data_field_flags (byonoyusbhid.h) +_FLAG_TYPE_MASK = 0x0F +_FLAG_TYPE_STRING = 0x02 +_FLAG_TYPE_INTEGER = 0x01 +_FLAG_TYPE_FLOAT = 0x04 +_FLAG_TYPE_BOOLEAN = 0x03 +_FLAG_HAS_MORE_DATA = 0x10 + + +class LedEffect(enum.IntEnum): + SOLID = 0x00 + PROGRESS = 0x01 + CYLON = 0x02 + RAINBOW = 0x03 + BLINKING = 0x04 + BREATHING = 0x05 + + _ACCEL_LSB_PER_G = 16384.0 # 14-bit signed @ ±2 g full scale @@ -260,6 +296,103 @@ async def get_supported_reports(self) -> List[int]: out.append(i) return out + async def read_data_field(self, field_index: int) -> object: + """Read a named device-data field via REP_DEVICE_DATA_READ_IN (0x0200). + + Returns the field's value typed per the response flags + (str / int / float / bool / bytes). Truncates if HAS_MORE_DATA is set + (shouldn't happen for the short identity strings; log if it does). + """ + payload = Writer().u16(field_index).u8(0).raw_bytes(b"\x00" * 57).finish() + response = await self.send_command( + report_id=0x0200, payload=payload, routing_info=b"\x80\x40" + ) + assert response is not None + r = Reader(response[2:]) + _ = r.u16() # echoed field_index + flags = r.u8() + data_type = flags & _FLAG_TYPE_MASK + if flags & _FLAG_HAS_MORE_DATA: + logger.warning( + "[Byonoy] field 0x%04X has more data than fits in one report; truncating", + field_index, + ) + raw = r.raw_bytes(52) + if data_type == _FLAG_TYPE_STRING: + return raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") + if data_type == _FLAG_TYPE_INTEGER: + return int.from_bytes(raw[:4], "little", signed=False) + if data_type == _FLAG_TYPE_FLOAT: + return Reader(raw[:4]).f32() + if data_type == _FLAG_TYPE_BOOLEAN: + return raw[0] != 0 + return raw # TypeBytes + + async def get_device_info(self) -> ByonoyDeviceInfo: + """Read identity strings (matches C lib's byonoy_get_device_information).""" + + async def s(idx: int) -> str: + v = await self.read_data_field(idx) + return v if isinstance(v, str) else str(v) + + return ByonoyDeviceInfo( + device_id=await s(_DD_DEVICE_ID), + device_name=await s(_DD_DEVICE_NAME), + manufacturer=await s(_DD_DEVICE_MANUFACTURER), + serial_no=await s(_DD_SERIAL_NO), + firmware_version=await s(_DD_FIRMWARE_VERSION), + ref_number=await s(_DD_REF_NUMBER), + ) + + async def cancel(self, report_id: int = 0x0340) -> None: + """Abort an in-progress measurement via REP_ABORT_REPORT_OUT (0x0060). + + `report_id` is the trigger report whose execution should be aborted. + Defaults to the lum96 trigger (0x0340). + """ + payload = Writer().u16(report_id).raw_bytes(b"\x00" * 58).finish() + await self.send_command(report_id=0x0060, payload=payload, wait_for_response=False) + logger.info("[Byonoy] sent abort for report 0x%04X", report_id) + + async def set_led_colours(self, colours: List[Tuple[int, int, int]]) -> None: + """Set the 20-LED bar colours via REP_LED_BAR_COLOURS_OUT (0x0350). + + Pads with black if fewer than 20 are given; truncates if more. + Also enables manual mode by setting LedEffect.SOLID with FLAG_LED_MANUAL. + """ + pixels = list(colours[:20]) + [(0, 0, 0)] * max(0, 20 - len(colours)) + w = Writer() + for r_, g, b in pixels: + w.u8(r_ & 0xFF).u8(g & 0xFF).u8(b & 0xFF) + await self.send_command( + report_id=0x0350, payload=w.finish(), wait_for_response=False + ) + + async def set_led_effect( + self, + effect: LedEffect, + effect_state: int = 0, + manual: bool = False, + duration_ms: int = 0, + ) -> None: + """Set the LED bar effect via REP_LED_BAR_EFFECTS_OUT (0x0351). + + Set `manual=True` when driving dynamic effects (PROGRESS, CYLON, ...) + where you want to advance frames yourself via `effect_state`. + """ + flags = 0x02 if manual else 0 # FLAG_LED_MANUAL + payload = ( + Writer() + .u8(int(effect)) + .u8(effect_state & 0xFF) + .u8(flags) + .u32(int(duration_ms)) + .finish() + ) + await self.send_command( + report_id=0x0351, payload=payload, wait_for_response=False + ) + async def get_versions(self) -> ByonoyVersions: """Read REP_VERSIONS_IN (0x0080): system / STM / ESP / bootloader versions.""" response = await self.send_command( From 72e319845e158a18434e532199df0cc3cfd51aba Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Tue, 5 May 2026 15:06:10 +0200 Subject: [PATCH 07/14] set_led_colours: enable manual SOLID before painting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version sent only the colours report (0x0350); per the firmware comment in led_bar_effects_out_t — "iff FLAG_LED_MANUAL is set effect_state controls dynamic effects ... else the stm will decide how to animate" — the colours would have been overwritten by whatever default animation the device runs. Now we set effect=SOLID with FLAG_LED_MANUAL first, then write the pixel buffer. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/backend.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 2df7f22b3d0..82576ef135e 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -357,9 +357,11 @@ async def cancel(self, report_id: int = 0x0340) -> None: async def set_led_colours(self, colours: List[Tuple[int, int, int]]) -> None: """Set the 20-LED bar colours via REP_LED_BAR_COLOURS_OUT (0x0350). - Pads with black if fewer than 20 are given; truncates if more. - Also enables manual mode by setting LedEffect.SOLID with FLAG_LED_MANUAL. + First switches the bar into manual SOLID mode (FLAG_LED_MANUAL) so the + firmware doesn't overwrite the colours with its own animation, then + sends the 20-pixel buffer. Pads with black if fewer than 20 are given. """ + await self.set_led_effect(LedEffect.SOLID, manual=True) pixels = list(colours[:20]) + [(0, 0, 0)] * max(0, 20 - len(colours)) w = Writer() for r_, g, b in pixels: From 9c47f6009442d109a669e9ddd20d8c13d495aafc Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Wed, 6 May 2026 12:33:50 +0200 Subject: [PATCH 08/14] selected_wells docstring: clarify it's an output filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical hardware test (8 wells in column 1, SENSITIVE) took 28 s instead of the ~3 s a true skip-mode would have produced — and the unselected wells came back exactly 0.00 rather than uninitialised garbage. The firmware scans the whole 96-well array regardless of the bitmask and zero-fills unselected wells before transmitting. Useful for cleaner downstream processing but does not reduce wall-clock read time; the docstring now says so plainly. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/luminescence_96.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index 4fe2720dd03..c3aa10f0df4 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -50,7 +50,10 @@ class LuminescenceParams(BackendParams): mode regardless of `mode`. Required when `mode == CUSTOM`. selected_wells: Optional 96-bool mask in plate row-major order (A1..H12). If None, the wells passed to `read_luminescence` decide which wells - are sampled (defaulting to all 96). + are reported (defaulting to all 96). Note: this is an output filter, + not a measurement optimisation — the firmware scans all 96 wells in + every read and zero-fills the unselected ones in the result. Useful + for cleaner downstream processing; does not reduce read time. """ mode: Lum96IntegrationMode = Lum96IntegrationMode.SENSITIVE From b69a22ac6463b9999fea316d289e724f216afaf2 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Wed, 6 May 2026 13:03:27 +0200 Subject: [PATCH 09/14] cancel: software-side bail-out via _abort_requested flag Hardware diagnostic confirmed firmware stops emitting 0x0600 chunks after we send the abort but never sends a closing notification, so the read loop waited the full 120 s hard timeout before raising. Adds _abort_requested on ByonoyBase. cancel() raises the flag (then sends the firmware abort as before). Lum96 read loop checks the flag each iteration and raises asyncio.CancelledError if set; the per-chunk io.read timeout is lowered from 30 s to 2 s so cancel response is bounded by ~2 s instead of ~30 s. The flag is reset at the top of read_luminescence so a stale cancel from a previous run can't kill a fresh measurement. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/backend.py | 7 +++++++ pylabrobot/byonoy/luminescence_96.py | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 82576ef135e..6108dab4a65 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -144,6 +144,7 @@ def __init__(self, pid: int, device_type: ByonoyDevice) -> None: self._ping_interval = 1.0 self._sending_pings = False self._device_type = device_type + self._abort_requested = False async def setup(self, backend_params: Optional[BackendParams] = None) -> None: await self.io.setup() @@ -347,9 +348,15 @@ async def s(idx: int) -> str: async def cancel(self, report_id: int = 0x0340) -> None: """Abort an in-progress measurement via REP_ABORT_REPORT_OUT (0x0060). + Empirically the firmware stops emitting result chunks but does not send + any closing notification, so we also raise an `_abort_requested` flag + that subclasses' read loops poll to bail out instead of waiting 120 s + for the hard timeout. + `report_id` is the trigger report whose execution should be aborted. Defaults to the lum96 trigger (0x0340). """ + self._abort_requested = True payload = Writer().u16(report_id).raw_bytes(b"\x00" * 58).finish() await self.send_command(report_id=0x0060, payload=payload, wait_for_response=False) logger.info("[Byonoy] sent abort for report 0x%04X", report_id) diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index c3aa10f0df4..efe0d1891b6 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -1,3 +1,4 @@ +import asyncio import logging import time from dataclasses import dataclass @@ -128,6 +129,7 @@ async def read_luminescence( .u8(0) # flags .finish() ) + self._abort_requested = False await self.send_command( report_id=0x0340, payload=payload3, @@ -138,11 +140,15 @@ async def read_luminescence( all_rows: List[Optional[float]] = [] while True: + if self._abort_requested: + self._abort_requested = False + logger.info("[Byonoy L96 pid=0x%04X] read aborted by cancel()", self.io.pid) + raise asyncio.CancelledError("Luminescence read aborted via cancel().") if time.time() - t0 > 120: logger.error("[Byonoy L96 pid=0x%04X] luminescence read timed out after 120s", self.io.pid) raise TimeoutError("Reading luminescence data timed out after 2 minutes.") - chunk = await self.io.read(64, timeout=30) + chunk = await self.io.read(64, timeout=2) if len(chunk) == 0: continue From 5c42861e03a1cf8be87f422e9da9669ffe17758a Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Wed, 6 May 2026 14:04:03 +0200 Subject: [PATCH 10/14] docs: lab user guide for the Byonoy L96 Markdown walkthrough aimed at someone running an actual luminescence assay rather than reverse-engineering the protocol. Covers the read shape and units, the four integration modes, well-selection caveat (output filter, no speed-up), single read / timed read / kinetic time series patterns, cancel, the device queries (status/env/info/versions/ api/supported_reports), LED bar control, an end-to-end luciferase recipe, and a troubleshooting table for the gotchas we hit during hardware bring-up (light leakage, USB exclusivity, slot_state=UNKNOWN when no plate is loaded). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../byonoy/luminescence_96/lab_guide.md | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 docs/user_guide/byonoy/luminescence_96/lab_guide.md diff --git a/docs/user_guide/byonoy/luminescence_96/lab_guide.md b/docs/user_guide/byonoy/luminescence_96/lab_guide.md new file mode 100644 index 00000000000..1df7a0633e8 --- /dev/null +++ b/docs/user_guide/byonoy/luminescence_96/lab_guide.md @@ -0,0 +1,345 @@ +# Byonoy Luminescence 96 — lab guide + +A walkthrough for running a luminescence assay on the Byonoy L96 from PyLabRobot. Assumes the device is plugged in via USB and you've installed `pylabrobot` (with `hid` and `hidapi`). + +The L96 is a 96-well luminescence-only plate reader. It reads emitted light per well, no excitation source. Communicates over USB HID (vid `0x16D0`, pid `0x119B`). + +--- + +## 1. Connect + +```python +from pylabrobot.byonoy import byonoy_l96 # or byonoy_l96a for the automate variant + +base, reader = byonoy_l96(name="l96") +await reader.setup() +``` + +`base` is a resource (a plate goes here); `reader` is both a resource and a device (the detector unit). After `setup()` the HID handle is open and a heartbeat thread is running. + +When you're done: + +```python +await reader.stop() +``` + +> **One process at a time.** macOS / Windows / Linux all give exclusive HID access. If a previous Python session crashed without `stop()`, the next `setup()` will fail with "device already open". Replug the USB cable to force-release. + +--- + +## 2. Load a plate + +```python +from pylabrobot.resources import Cor_96_wellplate_360ul_Fb + +base.reader_unit_holder.unassign_child_resource(reader) # take detector off +plate = Cor_96_wellplate_360ul_Fb(name="plate") +base.plate_holder.assign_child_resource(plate) +# physically: place the plate in the reader, place the detector back on top +``` + +The reader-on-base interlock prevents you from assigning a plate while the detector is still on the holder — it forces a sane physical sequence. + +--- + +## 3. Read — the basics + +```python +results = await reader.luminescence.read(plate=plate, focal_height=13.0) +data = results[0].data # 8 × 12 list[list[float]] +timestamp = results[0].timestamp # epoch seconds +``` + +### Result shape + +`data` is plate row-major: + +``` +data[0] = [A1, A2, A3, ..., A12] +data[1] = [B1, B2, ..., B12] +... +data[7] = [H1, H2, ..., H12] +``` + +So `data[2][5]` is well `C6`. Values are floats in **RLU (Relative Light Units) per integration period** — not per second. Doubling integration time roughly doubles signal *and* dark counts. + +### Background + +With nothing in the wells (and a dark environment), expect noise around ±50 RLU at SENSITIVE (2 s). Non-zero noise is dark-current spread; **negative values are normal** because the firmware applies a baseline subtraction. + +> **Light leakage.** The L96 is designed to be light-tight from above (the detector unit covers the plate) but the bottom housing isn't perfectly sealed. Reading on a *white* surface vs a *black* surface can change empty-well readings from ~50 to ~50,000+ RLU because reflected ambient light leaks in. For real assays use a black mat or a dark cabinet. + +--- + +## 4. Picking an integration mode + +Four modes, mapping to the byonoy_device_library presets: + +| Mode | Integration time | Use for | +|---|---|---| +| `RAPID` | 100 ms | Saturation checks, quick "is it bright?" | +| `SENSITIVE` | 2 s (default) | Most assays — luciferase, BRET, NanoBiT | +| `ULTRA_SENSITIVE` | 20 s | Very faint signals; low-copy reporters | +| `CUSTOM` | user-supplied | Your own duration | + +```python +from pylabrobot.byonoy import ByonoyLuminescence96Backend, Lum96IntegrationMode + +# Preset +results = await reader.luminescence.read( + plate=plate, + focal_height=13.0, + backend_params=ByonoyLuminescence96Backend.LuminescenceParams( + mode=Lum96IntegrationMode.ULTRA_SENSITIVE, + ), +) + +# Custom (any duration in seconds) +results = await reader.luminescence.read( + plate=plate, + focal_height=13.0, + backend_params=ByonoyLuminescence96Backend.LuminescenceParams( + integration_time=5.0, # auto-switches to CUSTOM mode + ), +) +``` + +--- + +## 5. Reading specific wells + +Pass a 96-bool mask in plate row-major order (A1 = index 0, A12 = 11, B1 = 12, …, H12 = 95): + +```python +# Only column 1 (A1, B1, ..., H1) +mask = [False] * 96 +for row in range(8): + mask[row * 12 + 0] = True + +results = await reader.luminescence.read( + plate=plate, + focal_height=13.0, + backend_params=ByonoyLuminescence96Backend.LuminescenceParams( + selected_wells=mask, + ), +) +``` + +Unselected wells come back as exactly `0.0`. The result shape is still 8×12 — it's an output filter, not a different report. + +> **No speed-up.** The firmware always integrates the whole 96-sensor array. Reading one column with `SENSITIVE` takes the same wall-clock as reading the full plate (~28 s in our hardware test). Use `selected_wells` to keep your downstream tidy, not to save time. If you want fast, use `RAPID` mode. + +--- + +## 6. Timed read (delay before reading) + +For a substrate-injection assay where you want a fixed delay between adding reagent and reading: + +```python +import asyncio + +# ... pipette substrate into the plate ... +await asyncio.sleep(60) # 60 s incubation +results = await reader.luminescence.read(plate=plate, focal_height=13.0) +``` + +Nothing special — `await asyncio.sleep` doesn't block the event loop, and the reader stays connected. + +--- + +## 7. Kinetic read (time series) + +Read the same plate every N seconds, collect a stack of matrices: + +```python +import asyncio, time + +frames = [] +duration_s = 600 # 10 minutes total +interval_s = 30 # one read every 30 s + +t_start = time.time() +while time.time() - t_start < duration_s: + t_read = time.time() + results = await reader.luminescence.read(plate=plate, focal_height=13.0) + frames.append({ + "t": t_read - t_start, + "data": results[0].data, + }) + # Sleep the *remainder* of the interval (read takes ~3 s for SENSITIVE) + elapsed = time.time() - t_read + if elapsed < interval_s: + await asyncio.sleep(interval_s - elapsed) + +print(f"collected {len(frames)} frames over {duration_s} s") +``` + +Storing as a list of `{t, data}` dicts is simple. Convert to `numpy` for analysis: + +```python +import numpy as np +matrix_stack = np.array([f["data"] for f in frames]) # shape (n_frames, 8, 12) +times = np.array([f["t"] for f in frames]) +``` + +For an 8 × 12 well at column `c`, row `r`: +```python +trace = matrix_stack[:, r, c] # (n_frames,) signal over time +``` + +> **Kinetic read budget**: with `SENSITIVE` (2 s) the wall-clock per read is around 3 s including overhead. So `interval_s` must be ≥ 3. With `ULTRA_SENSITIVE` (20 s) it's around 28 s — plan accordingly. + +--- + +## 8. Stopping a long read + +If you need to bail out of an `ULTRA_SENSITIVE` read mid-flight (or any read takes longer than expected): + +```python +# Start the read in a task, cancel from elsewhere +task = asyncio.create_task( + reader.luminescence.read(plate=plate, focal_height=13.0, + backend_params=ByonoyLuminescence96Backend.LuminescenceParams( + mode=Lum96IntegrationMode.ULTRA_SENSITIVE, + ), + ) +) +# ... later: +await reader.driver.cancel(report_id=0x0340) +try: + await task +except asyncio.CancelledError: + print("aborted cleanly") +``` + +`cancel()` raises a flag the read loop checks; bail-out is within ~2 s. After cancel the device stays initialised and immediately accepts new reads — no need to `setup()` again. + +--- + +## 9. Device health & identity + +Useful at the start of a session, in error messages, or for run logging. + +```python +status = await reader.driver.get_status() +# ByonoyStatus(is_initialized, slot_state, error_code, uptime_s, is_measuring, boot_completed) + +env = await reader.driver.get_environment() +# ByonoyEnvironment(temperature_c, humidity, acceleration_g) + +info = await reader.driver.get_device_info() +# device_id, device_name, manufacturer, serial_no, firmware_version, ref_number + +versions = await reader.driver.get_versions() +# ByonoyVersions with system / STM / ESP / bootloader version numbers; .is_production + +api = await reader.driver.get_api_version() # protocol version +supported = await reader.driver.get_supported_reports() # list of HID report IDs + +print(f"{info.device_name} sn={info.serial_no} fw={info.firmware_version}") +print(f" uptime {status.uptime_s} s, T={env.temperature_c:.1f}°C, RH={env.humidity*100:.0f}%") +print(f" slot: {status.slot_state.name}") +``` + +> **`slot_state` interpretation**: `OCCUPIED` when a plate is loaded, `UNKNOWN` when nothing is in the reader (the firmware can't tell empty from missing). Don't treat `UNKNOWN` as an error — it's just "no plate". + +--- + +## 10. Visual feedback (LED bar) + +The L96 has a 20-pixel RGB front bar. Useful in a workcell to flag run state ("queued", "reading", "done", "errored"). + +```python +from pylabrobot.byonoy import LedEffect + +# Solid colour — auto-enables manual mode +await reader.driver.set_led_colours([(255, 200, 0)] * 20) # amber: queued +await reader.driver.set_led_colours([(0, 255, 0)] * 20) # green: ready +await reader.driver.set_led_colours([(255, 0, 0)] * 20) # red: error + +# Or per-pixel +gradient = [(int(255 * i / 20), 0, int(255 * (1 - i / 20))) for i in range(20)] +await reader.driver.set_led_colours(gradient) + +# Built-in firmware effects +await reader.driver.set_led_effect(LedEffect.BREATHING, duration_ms=10000) +await reader.driver.set_led_effect(LedEffect.CYLON, duration_ms=5000) +await reader.driver.set_led_effect(LedEffect.SOLID, duration_ms=0) # back to default +``` + +Available effects: `SOLID`, `BLINKING`, `BREATHING`, `CYLON`, `RAINBOW`, `PROGRESS`. The visual rendering of dynamic effects (`CYLON`, `RAINBOW`, ...) is firmware-defined; `set_led_colours` is the precise way to control exactly what you see. + +--- + +## 11. Common workflow recipe — luciferase end-point + +Putting it together for a typical end-point luciferase assay: + +```python +import asyncio, time +import numpy as np +from pylabrobot.byonoy import ( + byonoy_l96, ByonoyLuminescence96Backend, + Lum96IntegrationMode, LedEffect, +) +from pylabrobot.resources import Cor_96_wellplate_360ul_Fb + +# 1. Connect +base, reader = byonoy_l96(name="l96") +await reader.setup() + +# Light up amber: device is being prepared +await reader.driver.set_led_colours([(255, 150, 0)] * 20) + +# 2. Sanity check +status = await reader.driver.get_status() +info = await reader.driver.get_device_info() +print(f"{info.device_name} sn={info.serial_no} — {status.slot_state.name}") +assert status.error_code == 0 + +# 3. Load plate +base.reader_unit_holder.unassign_child_resource(reader) +plate = Cor_96_wellplate_360ul_Fb(name="assay_plate") +base.plate_holder.assign_child_resource(plate) +# (operator places plate, places detector back on top) + +# 4. Read — show green while measuring +await reader.driver.set_led_colours([(0, 255, 0)] * 20) +results = await reader.luminescence.read( + plate=plate, + focal_height=13.0, + backend_params=ByonoyLuminescence96Backend.LuminescenceParams( + mode=Lum96IntegrationMode.SENSITIVE, + ), +) +data = np.array(results[0].data) # 8 × 12 + +# 5. Save + tidy up +np.save(f"luminescence_{int(time.time())}.npy", data) +await reader.driver.set_led_effect(LedEffect.SOLID, duration_ms=0) +await reader.stop() +``` + +--- + +## 12. Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `setup()` raises "device already open" | Previous Python session left HID handle locked | Replug USB cable, or kill stale Python processes | +| All wells read very high (10⁴–10⁶) with no sample | Light leak through housing bottom | Use a dark mat or close the room | +| Strong gradient A→H with no sample | Directional light leak (one side leakier) | Same — dark mat | +| `slot_state=UNKNOWN` | No plate loaded | Expected — firmware cannot detect "nothing" definitively | +| `slot_state=OCCUPIED` but plate is the wrong one | Sensor only checks presence, not identity | Track plate identity in your code | +| Read takes 28 s for one well in SENSITIVE | Firmware always integrates 96 wells | Use `RAPID` for fast, accept the 28 s for SENSITIVE | +| `cancel()` doesn't abort | Wrong report id | Default `0x0340` is the lum trigger; usually correct | +| Negative readings on empty wells | Firmware baseline subtraction | Expected — they should sit around zero | + +--- + +## 13. Reference + +- **Hardware protocol**: HID 64-byte frames, vid `0x16D0`, pid `0x119B`. Report IDs decoded from Byonoy's `byonoyusbhid.h`. `routing_info=\x80\x40` requests a reply; `\x00\x00` is fire-and-forget. +- **Source**: `pylabrobot/byonoy/backend.py` (transport + queries), `pylabrobot/byonoy/luminescence_96.py` (lum read). +- **Vendor library**: `byonoy_device_library` (C with pybind11 wrapper). Not a runtime dependency for PLR — every byte is decoded from the headers and goes through PLR's own HID transport. +- **Companion notebook**: `docs/user_guide/byonoy/luminescence_96/hello-world.ipynb` for a minimal run-through. From ed6ccae0420c8ac9596a7bcb449a2550d411fcc8 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Wed, 6 May 2026 14:48:37 +0200 Subject: [PATCH 11/14] Firmware error-code decoding via overridable per-backend table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors Byonoy's own structure: a generic Status::firmwareErrorId base that just stringifies the hex byte, with per-device overrides where the firmware codes are documented. Concretely: - Abs96StatusError IntEnum from hid-reports/.../abs96status.cpp (NO_ERROR, ERROR_CALIB, ERROR_AMBIENT, ERROR_USB, ERROR_HARDWARE, ERROR_TEMPERATURE, ERROR_NO_MEASUREMENTUNIT, ERROR_NO_ACK) - Abs1StatusError IntFlag from .../abs1status.cpp (bit-flag set) - ByonoyBase._ERROR_NAMES default = {0: NO_ERROR}, overridable - ByonoyAbsorbance96Backend overrides _ERROR_NAMES = ABS96_ERROR_NAMES - Lum96 inherits the default (no Lum-specific table is documented in the Byonoy source — pretending otherwise would be guessing) - describe_error_code(code) returns the name or "errorCode=0xNN" (matches the C library's generic stringifier byte-for-byte). Future per-device backends (AbsOne, Lum384, Flu96) get a one-line override when their tables are added. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/__init__.py | 2 + pylabrobot/byonoy/absorbance_96.py | 4 +- pylabrobot/byonoy/backend.py | 63 +++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/pylabrobot/byonoy/__init__.py b/pylabrobot/byonoy/__init__.py index 80cb5ccef31..20d1dca32ba 100644 --- a/pylabrobot/byonoy/__init__.py +++ b/pylabrobot/byonoy/__init__.py @@ -1,4 +1,6 @@ from .backend import ( + Abs1StatusError, + Abs96StatusError, ByonoyDeviceInfo, ByonoyEnvironment, ByonoyStatus, diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index 1cc7f59c09a..7bea4fc5b32 100644 --- a/pylabrobot/byonoy/absorbance_96.py +++ b/pylabrobot/byonoy/absorbance_96.py @@ -2,7 +2,7 @@ import time from typing import List, Optional, Tuple -from pylabrobot.byonoy.backend import ByonoyBase, ByonoyDevice +from pylabrobot.byonoy.backend import ABS96_ERROR_NAMES, ByonoyBase, ByonoyDevice from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.absorbance import ( Absorbance, @@ -29,6 +29,8 @@ class ByonoyAbsorbance96Backend(ByonoyBase, AbsorbanceBackend): """Backend for the Byonoy Absorbance 96 Automate plate reader.""" + _ERROR_NAMES = ABS96_ERROR_NAMES + def __init__(self) -> None: super().__init__(pid=0x1199, device_type=ByonoyDevice.ABSORBANCE_96) self.available_wavelengths: List[float] = [] diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 6108dab4a65..56d1af2e92a 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -5,7 +5,7 @@ import time from abc import ABCMeta from dataclasses import dataclass -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from pylabrobot.capabilities.capability import BackendParams from pylabrobot.device import Driver @@ -130,12 +130,59 @@ class LedEffect(enum.IntEnum): BREATHING = 0x05 +# --- Firmware error codes (per Byonoy hid-reports source) ------------------- +# +# The status_in_t.error_code byte is device-specific. Byonoy's own C library +# defines a Status base class that just stringifies the hex code, with per- +# device subclasses (Abs96Status, Abs1Status) providing named tables. There +# is no documented Lum96 table — Lum96 inherits the generic stringifier. +# +# These mirror the enums in: +# hid-reports/src/hid/report/request/abs96status.cpp +# hid-reports/src/hid/report/request/abs1status.cpp + + +class Abs96StatusError(enum.IntEnum): + NO_ERROR = 0 + ERROR_CALIB = 1 + ERROR_AMBIENT = 2 + ERROR_USB = 3 + ERROR_HARDWARE = 4 + ERROR_TEMPERATURE = 5 + ERROR_NO_MEASUREMENTUNIT = 6 + ERROR_NO_ACK = 10 + + +class Abs1StatusError(enum.IntFlag): + """AbsOne errors are a bit-flag set — multiple can be raised at once.""" + NO_ERROR = 0 + AMBIENT_LIGHT = 1 + MIN_LIGHT = 2 + USB = 4 + HARDWARE = 8 + EEPROM = 16 + TIMEOUT = 32 + POWER_CALIBRATION = 64 + NOISE_LIMIT = 128 + + +_GENERIC_ERROR_NAMES: Dict[int, str] = {0: "NO_ERROR"} +ABS96_ERROR_NAMES: Dict[int, str] = {e.value: e.name for e in Abs96StatusError} +ABS1_ERROR_NAMES: Dict[int, str] = {e.value: e.name for e in Abs1StatusError} + + _ACCEL_LSB_PER_G = 16384.0 # 14-bit signed @ ±2 g full scale class ByonoyBase(Driver, metaclass=ABCMeta): """Shared HID communication logic for Byonoy plate readers.""" + # Firmware error-code → name mapping. Default mirrors Byonoy's generic + # Status::firmwareErrorId (only NO_ERROR is documented). Subclasses for + # specific devices (e.g. ByonoyAbsorbance96Backend) override with their + # documented tables. Lum96 has no documented table; inherits the default. + _ERROR_NAMES: Dict[int, str] = _GENERIC_ERROR_NAMES + def __init__(self, pid: int, device_type: ByonoyDevice) -> None: super().__init__() self.io = HID(human_readable_device_name="Byonoy Plate Reader", vid=0x16D0, pid=pid) @@ -239,6 +286,20 @@ async def get_status(self) -> ByonoyStatus: boot_completed=r.u8() != 0, ) + def describe_error_code(self, code: int) -> str: + """Return a human-readable name for a firmware error_code byte. + + Looks up `code` in this backend's `_ERROR_NAMES` table. Unknown codes + fall back to `"errorCode=0xNN"` matching the C library's generic + Status::firmwareErrorId. The default table only has NO_ERROR (0); + subclasses for documented devices (Abs96, AbsOne) populate richer + tables. Lum96 has no documented table — codes other than 0 will + surface as the hex sentinel, which is the honest answer. + """ + if code in self._ERROR_NAMES: + return self._ERROR_NAMES[code] + return f"errorCode=0x{code:02x}" + async def get_environment(self) -> ByonoyEnvironment: """Read REP_ENVIRONMENT_IN (0x0310): temperature, humidity, acceleration.""" response = await self.send_command( From d28c0aebe9d521bfee68142f43fd20fd1822ca32 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Wed, 6 May 2026 14:49:15 +0200 Subject: [PATCH 12/14] =?UTF-8?q?docs:=20lab=20guide=20=E2=80=94=20mention?= =?UTF-8?q?=20describe=5Ferror=5Fcode()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/user_guide/byonoy/luminescence_96/lab_guide.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/user_guide/byonoy/luminescence_96/lab_guide.md b/docs/user_guide/byonoy/luminescence_96/lab_guide.md index 1df7a0633e8..97a93562454 100644 --- a/docs/user_guide/byonoy/luminescence_96/lab_guide.md +++ b/docs/user_guide/byonoy/luminescence_96/lab_guide.md @@ -238,11 +238,13 @@ supported = await reader.driver.get_supported_reports() # list of HID report ID print(f"{info.device_name} sn={info.serial_no} fw={info.firmware_version}") print(f" uptime {status.uptime_s} s, T={env.temperature_c:.1f}°C, RH={env.humidity*100:.0f}%") -print(f" slot: {status.slot_state.name}") +print(f" slot: {status.slot_state.name}, error: {reader.driver.describe_error_code(status.error_code)}") ``` > **`slot_state` interpretation**: `OCCUPIED` when a plate is loaded, `UNKNOWN` when nothing is in the reader (the firmware can't tell empty from missing). Don't treat `UNKNOWN` as an error — it's just "no plate". +> **`error_code` interpretation**: `0` is `NO_ERROR`. The Lum96 firmware doesn't publish a documented table for non-zero values, so non-zero codes surface as `errorCode=0xNN` (matching what Byonoy's own C library returns). For Abs96 / AbsOne backends, names like `ERROR_CALIB` / `AMBIENT_LIGHT` are decoded automatically. + --- ## 10. Visual feedback (LED bar) From 40d5930f83ef5efd9817eb6baf256fa77aa23d22 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Wed, 6 May 2026 15:04:50 +0200 Subject: [PATCH 13/14] docs: architecture notes from v1b1-capability review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the v1b1 review results so a future architectural refactor has the context. Covers the pre-existing Driver/CapabilityBackend collapse (predates this branch) plus the five findings introduced by this branch's diff: F1 LED → P-16 helper, F2 diagnostics → P-16 helper, F3 LuminescenceParams shape (positive), F4 propagate _abort_requested check to absorbance_96 read loop, F5 ByonoyBase → ByonoyDriver rename. Concrete shape suggestions and v1b1 precedent cited per finding (STARCover / WashStation / NimbusDoor for the helper pattern; TecanInfiniteDriver for the multi-backend shared- driver shape). Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/ARCHITECTURE_NOTES.md | 160 ++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 pylabrobot/byonoy/ARCHITECTURE_NOTES.md diff --git a/pylabrobot/byonoy/ARCHITECTURE_NOTES.md b/pylabrobot/byonoy/ARCHITECTURE_NOTES.md new file mode 100644 index 00000000000..58b98a087e5 --- /dev/null +++ b/pylabrobot/byonoy/ARCHITECTURE_NOTES.md @@ -0,0 +1,160 @@ +# Byonoy package — architecture notes for future refactors + +These notes capture the v1b1-capability review results from the +`byonoy-luminescence` branch (12 commits, HEAD `d28c0aebe`) so the +context is preserved for whoever next reorganises this module. They +are advisory — the package works as-is and ships in v1b1. + +## Pre-existing structural divergence from canonical v1b1 + +The pre-existing `ByonoyBase` (inherited from `upstream/v1b1`) collapses +the `Driver` and `CapabilityBackend` layers into one class: + +``` +ByonoyBase(Driver, metaclass=ABCMeta) # acts as both Driver + base for Backends + └─ ByonoyLuminescence96Backend(ByonoyBase, LuminescenceBackend) + └─ ByonoyAbsorbance96Backend(ByonoyBase, AbsorbanceBackend) +``` + +`ByonoyLuminescence96Backend` is therefore *both* a `Driver` and a +`LuminescenceBackend`. Compared to canonical v1b1: + +- **P-06 (four-layer architecture)**: not separated — the `Driver` and + `CapabilityBackend` are fused. +- **P-05 (backend stores `_driver` reference)**: not applicable — the + backend *is* the driver. +- **P-08 (`Driver` naming)**: `ByonoyBase` does not + follow the convention. v1b1 precedent: `BioShakeDriver`, + `NimbusDriver`, `XArm6Driver`, `STARDriver`, `TecanInfiniteDriver`. +- **P-25 (lifecycle hook scope)**: capability-specific init lives in + `setup` instead of `_on_setup` (visible in + `ByonoyAbsorbance96Backend.setup`, which calls + `initialize_measurements` and `request_available_absorbance_wavelengths` + inside the driver-level `setup`). Pre-existing in upstream/v1b1. + +When a future PR refactors: + +1. Introduce `class ByonoyDriver(Driver)` carrying the HID transport, + heartbeat thread, `send_command`, the device-info methods, the + abort flag, and the LED operations. +2. Make `ByonoyLuminescence96Backend(LuminescenceBackend)` a plain + `CapabilityBackend` that takes a `driver: ByonoyDriver` in + `__init__` and stores it as `self._driver`. +3. Move capability-specific work (the abs96 wavelength discovery, + `initialize_measurements`) from `setup` into `_on_setup`. +4. The Device class stays at `ByonoyLuminescence96(Resource, Device)` + and constructs the driver + backend separately, then wires + `_capabilities = [self.luminescence]`. v1b1 precedent for the + driver-shared-across-multiple-backends shape: + `pylabrobot/tecan/infinite/infinite.py:31-75` — `TecanInfinite200Pro` + wires `Absorbance`, `Fluorescence`, `Luminescence`, `LoadingTray` + backends onto a single `TecanInfiniteDriver`. + +## Findings introduced by the `byonoy-luminescence` branch + +### F1 — LED control could be a P-16 helper subsystem (soft) + +`set_led_colours` and `set_led_effect` live as flat methods on the +`Driver`. They form a coherent subsystem (touch reports 0x0350 / 0x0351, +share manual-mode coordination — `set_led_colours` already chains an +effect-set + colour-write). v1b1 precedent: `STARCover`, +`STARWashStation`, `NimbusDoor` group related operations into a plain +helper class attached as a Driver attribute, with `_on_setup` / +`_on_stop` hooks. + +Suggested shape: + +```python +class ByonoyLEDBar: + """Plain helper class (not a CapabilityBackend), following the + STARCover pattern. Drives the 20-pixel front bar.""" + def __init__(self, driver: ByonoyDriver) -> None: + self._driver = driver + async def _on_setup(self) -> None: pass + async def _on_stop(self) -> None: pass + async def set_colours(self, colours: List[Tuple[int, int, int]]) -> None: ... + async def set_effect(self, effect: LedEffect, ...) -> None: ... +``` + +User call site changes from `reader.driver.set_led_colours(...)` to +`reader.driver.led_bar.set_colours(...)`. + +### F2 — Device-info queries could be a P-16 helper subsystem (soft) + +Eight related methods on the `Driver` (`get_status`, `get_environment`, +`get_versions`, `get_api_version`, `get_supported_reports`, +`read_data_field`, `get_device_info`, `describe_error_code`) plus a +class-attribute extension hook (`_ERROR_NAMES`). The override is +currently per-backend-subclass (`ByonoyAbsorbance96Backend._ERROR_NAMES += ABS96_ERROR_NAMES`); a helper class would localise the override +surface alongside the methods that consume it. + +Suggested shape: + +```python +class ByonoyDiagnostics: + """Plain helper class (not a CapabilityBackend), following the + STARCover pattern. Reads device metadata and decodes firmware + errors per the device's known table.""" + _ERROR_NAMES: Dict[int, str] = _GENERIC_ERROR_NAMES # override per device + + def __init__(self, driver: ByonoyDriver) -> None: + self._driver = driver + async def _on_setup(self) -> None: pass + async def _on_stop(self) -> None: pass + async def get_status(self) -> ByonoyStatus: ... + async def get_environment(self) -> ByonoyEnvironment: ... + # ... etc. + def describe_error_code(self, code: int) -> str: ... +``` + +Per-device subclasses (`Abs96Diagnostics(ByonoyDiagnostics)`) override +`_ERROR_NAMES`. The Driver constructs the right subclass per its +device type. + +### F3 — `LuminescenceParams` shape is correct (informational, positive) + +The new `mode` / `integration_time` / `selected_wells` fields on a +typed dataclass inheriting `BackendParams` match v1b1 idiom (P-22). +The integration-mode preset table (`LUM96_PRESET_S`) is co-located. +The `integration_time is not None → CUSTOM` resolution preserves the +legacy call shape. No change needed. + +### F4 — `_abort_requested` flag should propagate to abs96 (soft) + +Setting and consuming the abort flag works because the backend *is* +the driver (collapse). With a Driver/Backend split, the flag belongs +on the Driver so all backends see it. Until then: copy the +`if self._abort_requested: ... raise asyncio.CancelledError(...)` +guard from `luminescence_96.py` read loop into +`absorbance_96.py:_run_abs_measurement`'s read loop. Same shape; one +block; makes `cancel()` consistent across both backends. + +### F5 — `ByonoyBase` → `ByonoyDriver` rename (soft, out of scope) + +The `Base` suffix is non-idiomatic. Every v1b1 device driver is named +`Driver`. When the architectural split (above) happens, +rename to `ByonoyDriver`. The per-device pid is already passed via +`__init__`, so no signature change. + +## Why the divergences are tolerable today + +- The package works on real hardware (validated against an L96 with + serial `BYOMAL00029`). +- The collapse predates this branch — splitting it is independent + refactoring work. +- The user-visible API (`reader.luminescence.read(...)`, + `reader.driver.get_status()`) doesn't depend on the internal + layering and would survive a refactor unchanged for callers. +- Helper-subsystem grouping (F1, F2) changes call sites + (`driver.led_bar.set_colours` vs `driver.set_led_colours`); worth + doing in a single coordinated PR rather than piecemeal. + +## Reference + +- v1b1-capability skill review run: `2026-05-06` +- Patterns cited: P-05, P-06, P-08, P-13, P-16, P-19, P-22, P-25 from + `~/.claude/skills/v1b1-capability/reference.md` +- v1b1 helper precedent: `pylabrobot/hamilton/liquid_handlers/star/cover.py`, + `wash_station.py`, `x_arm.py`, `autoload.py`, and + `pylabrobot/hamilton/liquid_handlers/nimbus/door.py` From fc13f5649017ab5822088d83ea7b8f67104ec9ab Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Thu, 7 May 2026 13:32:26 +0200 Subject: [PATCH 14/14] Address PR #1027 feedback: focal_height note + notebook conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - focal_height: ABC requires the parameter so we accept it, but the L96 has fixed optics (detector clamps onto base; geometry determined by plate + base + detector heights, not user-tunable). Updated the read_luminescence docstring to say so plainly. The docs example used `focal_height=13.0` which was misleading; replaced with `0`. - lab_guide.md → lab_guide.ipynb: same 13 sections, now runnable via Jupyter. Per Rick's request that people can run it directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../byonoy/luminescence_96/lab_guide.ipynb | 294 +++++++++++++++ .../byonoy/luminescence_96/lab_guide.md | 347 ------------------ pylabrobot/byonoy/luminescence_96.py | 7 +- 3 files changed, 300 insertions(+), 348 deletions(-) create mode 100644 docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb delete mode 100644 docs/user_guide/byonoy/luminescence_96/lab_guide.md diff --git a/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb b/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb new file mode 100644 index 00000000000..f7f09e8495e --- /dev/null +++ b/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb @@ -0,0 +1,294 @@ +{ + "cells": [ + {"cell_type": "markdown", "id": "intro", "metadata": {}, "source": [ + "# Byonoy Luminescence 96 — lab guide\n", + "\n", + "Run a luminescence assay on the Byonoy L96 from PyLabRobot. Assumes the device is plugged in via USB and you've installed `pylabrobot` (with `hid` and `hidapi`).\n", + "\n", + "The L96 is a 96-well luminescence-only plate reader. It reads emitted light per well, no excitation source. Communicates over USB HID (vid `0x16D0`, pid `0x119B`)." + ]}, + {"cell_type": "markdown", "id": "s1-md", "metadata": {}, "source": [ + "## 1. Connect\n", + "\n", + "`base` is a resource (a plate goes here); `reader` is both a resource and a device (the detector unit). After `setup()` the HID handle is open and a heartbeat thread is running.\n", + "\n", + "> **One process at a time.** macOS / Windows / Linux all give exclusive HID access. If a previous Python session crashed without `stop()`, the next `setup()` will fail with \"device already open\". Replug the USB cable to force-release." + ]}, + {"cell_type": "code", "execution_count": null, "id": "s1-code", "metadata": {}, "outputs": [], "source": [ + "from pylabrobot.byonoy import byonoy_l96 # or byonoy_l96a for the automate variant\n", + "\n", + "base, reader = byonoy_l96(name=\"l96\")\n", + "await reader.setup()" + ]}, + {"cell_type": "markdown", "id": "s2-md", "metadata": {}, "source": [ + "## 2. Load a plate\n", + "\n", + "The reader-on-base interlock prevents you from assigning a plate while the detector is still on the holder — it forces a sane physical sequence.\n", + "\n", + "After running this cell, physically place the plate in the reader and place the detector back on top." + ]}, + {"cell_type": "code", "execution_count": null, "id": "s2-code", "metadata": {}, "outputs": [], "source": [ + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "base.reader_unit_holder.unassign_child_resource(reader) # take detector off\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", + "base.plate_holder.assign_child_resource(plate)" + ]}, + {"cell_type": "markdown", "id": "s3-md", "metadata": {}, "source": [ + "## 3. Read — the basics\n", + "\n", + "`focal_height` is required by the abstract `Luminescence` capability but **ignored by the L96** — the device has a fixed optical configuration (the detector unit clamps onto the base; geometry is determined by plate + base + detector heights, not user-tunable). Pass `0` by convention.\n", + "\n", + "### Result shape\n", + "\n", + "`data` is plate row-major: `data[0]` = `[A1..A12]`, `data[1]` = `[B1..B12]`, ..., `data[7]` = `[H1..H12]`. So `data[2][5]` is well `C6`. Values are floats in **RLU (Relative Light Units) per integration period** — not per second. Doubling integration time roughly doubles signal *and* dark counts.\n", + "\n", + "### Background\n", + "\n", + "With nothing in the wells (and a dark environment), expect noise around ±50 RLU at SENSITIVE (2 s). Non-zero noise is dark-current spread; **negative values are normal** because the firmware applies a baseline subtraction.\n", + "\n", + "> **Light leakage.** The L96 is designed to be light-tight from above (the detector unit covers the plate) but the bottom housing isn't perfectly sealed. Reading on a *white* surface vs a *black* surface can change empty-well readings from ~50 to ~50,000+ RLU because reflected ambient light leaks in. For real assays use a black mat or a dark cabinet." + ]}, + {"cell_type": "code", "execution_count": null, "id": "s3-code", "metadata": {}, "outputs": [], "source": [ + "results = await reader.luminescence.read(plate=plate, focal_height=0)\n", + "data = results[0].data # 8 × 12 list[list[float]]\n", + "timestamp = results[0].timestamp # epoch seconds\n", + "\n", + "print(f\"timestamp={timestamp}\")\n", + "for row in data:\n", + " print(\" \" + \" \".join(f\"{v:8.2f}\" for v in row))" + ]}, + {"cell_type": "markdown", "id": "s4-md", "metadata": {}, "source": [ + "## 4. Picking an integration mode\n", + "\n", + "Four modes, mapping to the byonoy_device_library presets:\n", + "\n", + "| Mode | Integration time | Use for |\n", + "|---|---|---|\n", + "| `RAPID` | 100 ms | Saturation checks, quick \"is it bright?\" |\n", + "| `SENSITIVE` | 2 s (default) | Most assays — luciferase, BRET, NanoBiT |\n", + "| `ULTRA_SENSITIVE` | 20 s | Very faint signals; low-copy reporters |\n", + "| `CUSTOM` | user-supplied | Your own duration |" + ]}, + {"cell_type": "code", "execution_count": null, "id": "s4-code", "metadata": {}, "outputs": [], "source": [ + "from pylabrobot.byonoy import ByonoyLuminescence96Backend, Lum96IntegrationMode\n", + "\n", + "# Preset\n", + "results = await reader.luminescence.read(\n", + " plate=plate,\n", + " focal_height=0,\n", + " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", + " mode=Lum96IntegrationMode.ULTRA_SENSITIVE,\n", + " ),\n", + ")\n", + "\n", + "# Custom (any duration in seconds) — auto-switches to CUSTOM mode\n", + "results = await reader.luminescence.read(\n", + " plate=plate,\n", + " focal_height=0,\n", + " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", + " integration_time=5.0,\n", + " ),\n", + ")" + ]}, + {"cell_type": "markdown", "id": "s5-md", "metadata": {}, "source": [ + "## 5. Reading specific wells\n", + "\n", + "Pass a 96-bool mask in plate row-major order (A1 = index 0, A12 = 11, B1 = 12, ..., H12 = 95). Unselected wells come back as exactly `0.0`. The result shape is still 8×12 — it's an output filter, not a different report.\n", + "\n", + "> **No speed-up.** The firmware always integrates the whole 96-sensor array. Reading one column with `SENSITIVE` takes the same wall-clock as reading the full plate (~28 s in our hardware test). Use `selected_wells` to keep your downstream tidy, not to save time. If you want fast, use `RAPID` mode." + ]}, + {"cell_type": "code", "execution_count": null, "id": "s5-code", "metadata": {}, "outputs": [], "source": [ + "# Only column 1 (A1, B1, ..., H1)\n", + "mask = [False] * 96\n", + "for row in range(8):\n", + " mask[row * 12 + 0] = True\n", + "\n", + "results = await reader.luminescence.read(\n", + " plate=plate,\n", + " focal_height=0,\n", + " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", + " selected_wells=mask,\n", + " ),\n", + ")" + ]}, + {"cell_type": "markdown", "id": "s6-md", "metadata": {}, "source": [ + "## 6. Timed read (delay before reading)\n", + "\n", + "For a substrate-injection assay where you want a fixed delay between adding reagent and reading. `await asyncio.sleep` doesn't block the event loop, and the reader stays connected." + ]}, + {"cell_type": "code", "execution_count": null, "id": "s6-code", "metadata": {}, "outputs": [], "source": [ + "import asyncio\n", + "\n", + "# ... pipette substrate into the plate ...\n", + "await asyncio.sleep(60) # 60 s incubation\n", + "results = await reader.luminescence.read(plate=plate, focal_height=0)" + ]}, + {"cell_type": "markdown", "id": "s7-md", "metadata": {}, "source": [ + "## 7. Kinetic read (time series)\n", + "\n", + "Read the same plate every N seconds, collect a stack of matrices. With `SENSITIVE` (2 s) the wall-clock per read is around 3 s including overhead, so `interval_s` must be ≥ 3. With `ULTRA_SENSITIVE` (20 s) it's around 28 s — plan accordingly." + ]}, + {"cell_type": "code", "execution_count": null, "id": "s7-code", "metadata": {}, "outputs": [], "source": [ + "import asyncio, time\n", + "import numpy as np\n", + "\n", + "frames = []\n", + "duration_s = 600 # 10 minutes total\n", + "interval_s = 30 # one read every 30 s\n", + "\n", + "t_start = time.time()\n", + "while time.time() - t_start < duration_s:\n", + " t_read = time.time()\n", + " results = await reader.luminescence.read(plate=plate, focal_height=0)\n", + " frames.append({\n", + " \"t\": t_read - t_start,\n", + " \"data\": results[0].data,\n", + " })\n", + " elapsed = time.time() - t_read\n", + " if elapsed < interval_s:\n", + " await asyncio.sleep(interval_s - elapsed)\n", + "\n", + "matrix_stack = np.array([f[\"data\"] for f in frames]) # (n_frames, 8, 12)\n", + "times = np.array([f[\"t\"] for f in frames])\n", + "print(f\"collected {len(frames)} frames over {duration_s} s\")\n", + "# Trace for well C6:\n", + "trace = matrix_stack[:, 2, 5]" + ]}, + {"cell_type": "markdown", "id": "s8-md", "metadata": {}, "source": [ + "## 8. Stopping a long read\n", + "\n", + "If you need to bail out of an `ULTRA_SENSITIVE` read mid-flight (or any read takes longer than expected), `cancel()` raises a flag the read loop checks; bail-out is within ~2 s. After cancel the device stays initialised and immediately accepts new reads." + ]}, + {"cell_type": "code", "execution_count": null, "id": "s8-code", "metadata": {}, "outputs": [], "source": [ + "task = asyncio.create_task(\n", + " reader.luminescence.read(plate=plate, focal_height=0,\n", + " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", + " mode=Lum96IntegrationMode.ULTRA_SENSITIVE,\n", + " ),\n", + " )\n", + ")\n", + "await asyncio.sleep(1.0)\n", + "await reader.driver.cancel(report_id=0x0340)\n", + "try:\n", + " await task\n", + "except asyncio.CancelledError:\n", + " print(\"aborted cleanly\")" + ]}, + {"cell_type": "markdown", "id": "s9-md", "metadata": {}, "source": [ + "## 9. Device health & identity\n", + "\n", + "Useful at the start of a session, in error messages, or for run logging.\n", + "\n", + "> **`slot_state`**: `OCCUPIED` when a plate is loaded, `UNKNOWN` when nothing is in the reader (the firmware can't tell empty from missing). Don't treat `UNKNOWN` as an error.\n", + ">\n", + "> **`error_code`**: `0` is `NO_ERROR`. The Lum96 firmware doesn't publish a documented table for non-zero values, so non-zero codes surface as `errorCode=0xNN` (matching what Byonoy's own C library returns). For Abs96 / AbsOne backends, names like `ERROR_CALIB` / `AMBIENT_LIGHT` are decoded automatically." + ]}, + {"cell_type": "code", "execution_count": null, "id": "s9-code", "metadata": {}, "outputs": [], "source": [ + "status = await reader.driver.get_status()\n", + "env = await reader.driver.get_environment()\n", + "info = await reader.driver.get_device_info()\n", + "versions = await reader.driver.get_versions()\n", + "api = await reader.driver.get_api_version()\n", + "supported = await reader.driver.get_supported_reports()\n", + "\n", + "print(f\"{info.device_name} sn={info.serial_no} fw={info.firmware_version}\")\n", + "print(f\" uptime {status.uptime_s} s, T={env.temperature_c:.1f}°C, RH={env.humidity*100:.0f}%\")\n", + "print(f\" slot: {status.slot_state.name}, error: {reader.driver.describe_error_code(status.error_code)}\")\n", + "print(f\" api v{api.version_no}, fw production={versions.is_production}\")\n", + "print(f\" supported reports ({len(supported)}): \" + \", \".join(f\"0x{i:04X}\" for i in supported))" + ]}, + {"cell_type": "markdown", "id": "s10-md", "metadata": {}, "source": [ + "## 10. Visual feedback (LED bar)\n", + "\n", + "The L96 has a 20-pixel RGB front bar. Useful in a workcell to flag run state. Available effects: `SOLID`, `BLINKING`, `BREATHING`, `CYLON`, `RAINBOW`, `PROGRESS`. The visual rendering of dynamic effects is firmware-defined; `set_led_colours` is the precise way to control exactly what you see." + ]}, + {"cell_type": "code", "execution_count": null, "id": "s10-code", "metadata": {}, "outputs": [], "source": [ + "from pylabrobot.byonoy import LedEffect\n", + "\n", + "# Solid colour — auto-enables manual mode\n", + "await reader.driver.set_led_colours([(255, 200, 0)] * 20) # amber: queued\n", + "await reader.driver.set_led_colours([(0, 255, 0)] * 20) # green: ready\n", + "await reader.driver.set_led_colours([(255, 0, 0)] * 20) # red: error\n", + "\n", + "# Built-in firmware effects\n", + "await reader.driver.set_led_effect(LedEffect.BREATHING, duration_ms=10000)\n", + "await reader.driver.set_led_effect(LedEffect.SOLID, duration_ms=0) # back to default" + ]}, + {"cell_type": "markdown", "id": "s11-md", "metadata": {}, "source": [ + "## 11. End-point luciferase recipe\n", + "\n", + "End-to-end workflow for a typical end-point luciferase assay." + ]}, + {"cell_type": "code", "execution_count": null, "id": "s11-code", "metadata": {}, "outputs": [], "source": [ + "import asyncio, time\n", + "import numpy as np\n", + "from pylabrobot.byonoy import (\n", + " byonoy_l96, ByonoyLuminescence96Backend,\n", + " Lum96IntegrationMode, LedEffect,\n", + ")\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "# Connect\n", + "base, reader = byonoy_l96(name=\"assay\")\n", + "await reader.setup()\n", + "await reader.driver.set_led_colours([(255, 150, 0)] * 20) # amber: prep\n", + "\n", + "# Sanity check\n", + "status = await reader.driver.get_status()\n", + "info = await reader.driver.get_device_info()\n", + "print(f\"{info.device_name} sn={info.serial_no} — {status.slot_state.name}\")\n", + "assert status.error_code == 0\n", + "\n", + "# Load plate\n", + "base.reader_unit_holder.unassign_child_resource(reader)\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"assay_plate\")\n", + "base.plate_holder.assign_child_resource(plate)\n", + "# (operator places plate, places detector back on top)\n", + "\n", + "# Read — green while measuring\n", + "await reader.driver.set_led_colours([(0, 255, 0)] * 20)\n", + "results = await reader.luminescence.read(\n", + " plate=plate,\n", + " focal_height=0,\n", + " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", + " mode=Lum96IntegrationMode.SENSITIVE,\n", + " ),\n", + ")\n", + "data = np.array(results[0].data) # 8 × 12\n", + "\n", + "# Save + tidy up\n", + "np.save(f\"luminescence_{int(time.time())}.npy\", data)\n", + "await reader.driver.set_led_effect(LedEffect.SOLID, duration_ms=0)\n", + "await reader.stop()" + ]}, + {"cell_type": "markdown", "id": "s12-md", "metadata": {}, "source": [ + "## 12. Troubleshooting\n", + "\n", + "| Symptom | Likely cause | Fix |\n", + "|---|---|---|\n", + "| `setup()` raises \"device already open\" | Previous Python session left HID handle locked | Replug USB cable, or kill stale Python processes |\n", + "| All wells read very high (10⁴–10⁶) with no sample | Light leak through housing bottom | Use a dark mat or close the room |\n", + "| Strong gradient A→H with no sample | Directional light leak (one side leakier) | Same — dark mat |\n", + "| `slot_state=UNKNOWN` | No plate loaded | Expected — firmware cannot detect \"nothing\" definitively |\n", + "| Read takes 28 s for one well in SENSITIVE | Firmware always integrates 96 wells | Use `RAPID` for fast, accept the 28 s for SENSITIVE |\n", + "| `cancel()` doesn't abort | Wrong report id | Default `0x0340` is the lum trigger; usually correct |\n", + "| Negative readings on empty wells | Firmware baseline subtraction | Expected — they should sit around zero |\n", + "| `focal_height` parameter has no effect | L96 has fixed optics | Expected — parameter is ABC contract, ignored on this device |" + ]}, + {"cell_type": "markdown", "id": "s13-md", "metadata": {}, "source": [ + "## 13. Reference\n", + "\n", + "- **Hardware protocol**: HID 64-byte frames, vid `0x16D0`, pid `0x119B`. Report IDs decoded from Byonoy's `byonoyusbhid.h`. `routing_info=\\x80\\x40` requests a reply; `\\x00\\x00` is fire-and-forget.\n", + "- **Source**: `pylabrobot/byonoy/backend.py` (transport + queries), `pylabrobot/byonoy/luminescence_96.py` (lum read).\n", + "- **Vendor library**: `byonoy_device_library` (C with pybind11 wrapper). Not a runtime dependency for PLR — every byte is decoded from the headers and goes through PLR's own HID transport.\n", + "- **Companion notebook**: `hello-world.ipynb` for a minimal run-through." + ]} + ], + "metadata": { + "kernelspec": {"display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3"}, + "language_info": {"name": "python", "version": "3.11.0"} + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/byonoy/luminescence_96/lab_guide.md b/docs/user_guide/byonoy/luminescence_96/lab_guide.md deleted file mode 100644 index 97a93562454..00000000000 --- a/docs/user_guide/byonoy/luminescence_96/lab_guide.md +++ /dev/null @@ -1,347 +0,0 @@ -# Byonoy Luminescence 96 — lab guide - -A walkthrough for running a luminescence assay on the Byonoy L96 from PyLabRobot. Assumes the device is plugged in via USB and you've installed `pylabrobot` (with `hid` and `hidapi`). - -The L96 is a 96-well luminescence-only plate reader. It reads emitted light per well, no excitation source. Communicates over USB HID (vid `0x16D0`, pid `0x119B`). - ---- - -## 1. Connect - -```python -from pylabrobot.byonoy import byonoy_l96 # or byonoy_l96a for the automate variant - -base, reader = byonoy_l96(name="l96") -await reader.setup() -``` - -`base` is a resource (a plate goes here); `reader` is both a resource and a device (the detector unit). After `setup()` the HID handle is open and a heartbeat thread is running. - -When you're done: - -```python -await reader.stop() -``` - -> **One process at a time.** macOS / Windows / Linux all give exclusive HID access. If a previous Python session crashed without `stop()`, the next `setup()` will fail with "device already open". Replug the USB cable to force-release. - ---- - -## 2. Load a plate - -```python -from pylabrobot.resources import Cor_96_wellplate_360ul_Fb - -base.reader_unit_holder.unassign_child_resource(reader) # take detector off -plate = Cor_96_wellplate_360ul_Fb(name="plate") -base.plate_holder.assign_child_resource(plate) -# physically: place the plate in the reader, place the detector back on top -``` - -The reader-on-base interlock prevents you from assigning a plate while the detector is still on the holder — it forces a sane physical sequence. - ---- - -## 3. Read — the basics - -```python -results = await reader.luminescence.read(plate=plate, focal_height=13.0) -data = results[0].data # 8 × 12 list[list[float]] -timestamp = results[0].timestamp # epoch seconds -``` - -### Result shape - -`data` is plate row-major: - -``` -data[0] = [A1, A2, A3, ..., A12] -data[1] = [B1, B2, ..., B12] -... -data[7] = [H1, H2, ..., H12] -``` - -So `data[2][5]` is well `C6`. Values are floats in **RLU (Relative Light Units) per integration period** — not per second. Doubling integration time roughly doubles signal *and* dark counts. - -### Background - -With nothing in the wells (and a dark environment), expect noise around ±50 RLU at SENSITIVE (2 s). Non-zero noise is dark-current spread; **negative values are normal** because the firmware applies a baseline subtraction. - -> **Light leakage.** The L96 is designed to be light-tight from above (the detector unit covers the plate) but the bottom housing isn't perfectly sealed. Reading on a *white* surface vs a *black* surface can change empty-well readings from ~50 to ~50,000+ RLU because reflected ambient light leaks in. For real assays use a black mat or a dark cabinet. - ---- - -## 4. Picking an integration mode - -Four modes, mapping to the byonoy_device_library presets: - -| Mode | Integration time | Use for | -|---|---|---| -| `RAPID` | 100 ms | Saturation checks, quick "is it bright?" | -| `SENSITIVE` | 2 s (default) | Most assays — luciferase, BRET, NanoBiT | -| `ULTRA_SENSITIVE` | 20 s | Very faint signals; low-copy reporters | -| `CUSTOM` | user-supplied | Your own duration | - -```python -from pylabrobot.byonoy import ByonoyLuminescence96Backend, Lum96IntegrationMode - -# Preset -results = await reader.luminescence.read( - plate=plate, - focal_height=13.0, - backend_params=ByonoyLuminescence96Backend.LuminescenceParams( - mode=Lum96IntegrationMode.ULTRA_SENSITIVE, - ), -) - -# Custom (any duration in seconds) -results = await reader.luminescence.read( - plate=plate, - focal_height=13.0, - backend_params=ByonoyLuminescence96Backend.LuminescenceParams( - integration_time=5.0, # auto-switches to CUSTOM mode - ), -) -``` - ---- - -## 5. Reading specific wells - -Pass a 96-bool mask in plate row-major order (A1 = index 0, A12 = 11, B1 = 12, …, H12 = 95): - -```python -# Only column 1 (A1, B1, ..., H1) -mask = [False] * 96 -for row in range(8): - mask[row * 12 + 0] = True - -results = await reader.luminescence.read( - plate=plate, - focal_height=13.0, - backend_params=ByonoyLuminescence96Backend.LuminescenceParams( - selected_wells=mask, - ), -) -``` - -Unselected wells come back as exactly `0.0`. The result shape is still 8×12 — it's an output filter, not a different report. - -> **No speed-up.** The firmware always integrates the whole 96-sensor array. Reading one column with `SENSITIVE` takes the same wall-clock as reading the full plate (~28 s in our hardware test). Use `selected_wells` to keep your downstream tidy, not to save time. If you want fast, use `RAPID` mode. - ---- - -## 6. Timed read (delay before reading) - -For a substrate-injection assay where you want a fixed delay between adding reagent and reading: - -```python -import asyncio - -# ... pipette substrate into the plate ... -await asyncio.sleep(60) # 60 s incubation -results = await reader.luminescence.read(plate=plate, focal_height=13.0) -``` - -Nothing special — `await asyncio.sleep` doesn't block the event loop, and the reader stays connected. - ---- - -## 7. Kinetic read (time series) - -Read the same plate every N seconds, collect a stack of matrices: - -```python -import asyncio, time - -frames = [] -duration_s = 600 # 10 minutes total -interval_s = 30 # one read every 30 s - -t_start = time.time() -while time.time() - t_start < duration_s: - t_read = time.time() - results = await reader.luminescence.read(plate=plate, focal_height=13.0) - frames.append({ - "t": t_read - t_start, - "data": results[0].data, - }) - # Sleep the *remainder* of the interval (read takes ~3 s for SENSITIVE) - elapsed = time.time() - t_read - if elapsed < interval_s: - await asyncio.sleep(interval_s - elapsed) - -print(f"collected {len(frames)} frames over {duration_s} s") -``` - -Storing as a list of `{t, data}` dicts is simple. Convert to `numpy` for analysis: - -```python -import numpy as np -matrix_stack = np.array([f["data"] for f in frames]) # shape (n_frames, 8, 12) -times = np.array([f["t"] for f in frames]) -``` - -For an 8 × 12 well at column `c`, row `r`: -```python -trace = matrix_stack[:, r, c] # (n_frames,) signal over time -``` - -> **Kinetic read budget**: with `SENSITIVE` (2 s) the wall-clock per read is around 3 s including overhead. So `interval_s` must be ≥ 3. With `ULTRA_SENSITIVE` (20 s) it's around 28 s — plan accordingly. - ---- - -## 8. Stopping a long read - -If you need to bail out of an `ULTRA_SENSITIVE` read mid-flight (or any read takes longer than expected): - -```python -# Start the read in a task, cancel from elsewhere -task = asyncio.create_task( - reader.luminescence.read(plate=plate, focal_height=13.0, - backend_params=ByonoyLuminescence96Backend.LuminescenceParams( - mode=Lum96IntegrationMode.ULTRA_SENSITIVE, - ), - ) -) -# ... later: -await reader.driver.cancel(report_id=0x0340) -try: - await task -except asyncio.CancelledError: - print("aborted cleanly") -``` - -`cancel()` raises a flag the read loop checks; bail-out is within ~2 s. After cancel the device stays initialised and immediately accepts new reads — no need to `setup()` again. - ---- - -## 9. Device health & identity - -Useful at the start of a session, in error messages, or for run logging. - -```python -status = await reader.driver.get_status() -# ByonoyStatus(is_initialized, slot_state, error_code, uptime_s, is_measuring, boot_completed) - -env = await reader.driver.get_environment() -# ByonoyEnvironment(temperature_c, humidity, acceleration_g) - -info = await reader.driver.get_device_info() -# device_id, device_name, manufacturer, serial_no, firmware_version, ref_number - -versions = await reader.driver.get_versions() -# ByonoyVersions with system / STM / ESP / bootloader version numbers; .is_production - -api = await reader.driver.get_api_version() # protocol version -supported = await reader.driver.get_supported_reports() # list of HID report IDs - -print(f"{info.device_name} sn={info.serial_no} fw={info.firmware_version}") -print(f" uptime {status.uptime_s} s, T={env.temperature_c:.1f}°C, RH={env.humidity*100:.0f}%") -print(f" slot: {status.slot_state.name}, error: {reader.driver.describe_error_code(status.error_code)}") -``` - -> **`slot_state` interpretation**: `OCCUPIED` when a plate is loaded, `UNKNOWN` when nothing is in the reader (the firmware can't tell empty from missing). Don't treat `UNKNOWN` as an error — it's just "no plate". - -> **`error_code` interpretation**: `0` is `NO_ERROR`. The Lum96 firmware doesn't publish a documented table for non-zero values, so non-zero codes surface as `errorCode=0xNN` (matching what Byonoy's own C library returns). For Abs96 / AbsOne backends, names like `ERROR_CALIB` / `AMBIENT_LIGHT` are decoded automatically. - ---- - -## 10. Visual feedback (LED bar) - -The L96 has a 20-pixel RGB front bar. Useful in a workcell to flag run state ("queued", "reading", "done", "errored"). - -```python -from pylabrobot.byonoy import LedEffect - -# Solid colour — auto-enables manual mode -await reader.driver.set_led_colours([(255, 200, 0)] * 20) # amber: queued -await reader.driver.set_led_colours([(0, 255, 0)] * 20) # green: ready -await reader.driver.set_led_colours([(255, 0, 0)] * 20) # red: error - -# Or per-pixel -gradient = [(int(255 * i / 20), 0, int(255 * (1 - i / 20))) for i in range(20)] -await reader.driver.set_led_colours(gradient) - -# Built-in firmware effects -await reader.driver.set_led_effect(LedEffect.BREATHING, duration_ms=10000) -await reader.driver.set_led_effect(LedEffect.CYLON, duration_ms=5000) -await reader.driver.set_led_effect(LedEffect.SOLID, duration_ms=0) # back to default -``` - -Available effects: `SOLID`, `BLINKING`, `BREATHING`, `CYLON`, `RAINBOW`, `PROGRESS`. The visual rendering of dynamic effects (`CYLON`, `RAINBOW`, ...) is firmware-defined; `set_led_colours` is the precise way to control exactly what you see. - ---- - -## 11. Common workflow recipe — luciferase end-point - -Putting it together for a typical end-point luciferase assay: - -```python -import asyncio, time -import numpy as np -from pylabrobot.byonoy import ( - byonoy_l96, ByonoyLuminescence96Backend, - Lum96IntegrationMode, LedEffect, -) -from pylabrobot.resources import Cor_96_wellplate_360ul_Fb - -# 1. Connect -base, reader = byonoy_l96(name="l96") -await reader.setup() - -# Light up amber: device is being prepared -await reader.driver.set_led_colours([(255, 150, 0)] * 20) - -# 2. Sanity check -status = await reader.driver.get_status() -info = await reader.driver.get_device_info() -print(f"{info.device_name} sn={info.serial_no} — {status.slot_state.name}") -assert status.error_code == 0 - -# 3. Load plate -base.reader_unit_holder.unassign_child_resource(reader) -plate = Cor_96_wellplate_360ul_Fb(name="assay_plate") -base.plate_holder.assign_child_resource(plate) -# (operator places plate, places detector back on top) - -# 4. Read — show green while measuring -await reader.driver.set_led_colours([(0, 255, 0)] * 20) -results = await reader.luminescence.read( - plate=plate, - focal_height=13.0, - backend_params=ByonoyLuminescence96Backend.LuminescenceParams( - mode=Lum96IntegrationMode.SENSITIVE, - ), -) -data = np.array(results[0].data) # 8 × 12 - -# 5. Save + tidy up -np.save(f"luminescence_{int(time.time())}.npy", data) -await reader.driver.set_led_effect(LedEffect.SOLID, duration_ms=0) -await reader.stop() -``` - ---- - -## 12. Troubleshooting - -| Symptom | Likely cause | Fix | -|---|---|---| -| `setup()` raises "device already open" | Previous Python session left HID handle locked | Replug USB cable, or kill stale Python processes | -| All wells read very high (10⁴–10⁶) with no sample | Light leak through housing bottom | Use a dark mat or close the room | -| Strong gradient A→H with no sample | Directional light leak (one side leakier) | Same — dark mat | -| `slot_state=UNKNOWN` | No plate loaded | Expected — firmware cannot detect "nothing" definitively | -| `slot_state=OCCUPIED` but plate is the wrong one | Sensor only checks presence, not identity | Track plate identity in your code | -| Read takes 28 s for one well in SENSITIVE | Firmware always integrates 96 wells | Use `RAPID` for fast, accept the 28 s for SENSITIVE | -| `cancel()` doesn't abort | Wrong report id | Default `0x0340` is the lum trigger; usually correct | -| Negative readings on empty wells | Firmware baseline subtraction | Expected — they should sit around zero | - ---- - -## 13. Reference - -- **Hardware protocol**: HID 64-byte frames, vid `0x16D0`, pid `0x119B`. Report IDs decoded from Byonoy's `byonoyusbhid.h`. `routing_info=\x80\x40` requests a reply; `\x00\x00` is fire-and-forget. -- **Source**: `pylabrobot/byonoy/backend.py` (transport + queries), `pylabrobot/byonoy/luminescence_96.py` (lum read). -- **Vendor library**: `byonoy_device_library` (C with pybind11 wrapper). Not a runtime dependency for PLR — every byte is decoded from the headers and goes through PLR's own HID transport. -- **Companion notebook**: `docs/user_guide/byonoy/luminescence_96/hello-world.ipynb` for a minimal run-through. diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index efe0d1891b6..6f7dda0cd3e 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -73,7 +73,12 @@ async def read_luminescence( Args: plate: The plate being read. wells: Wells to measure. - focal_height: Focal height in mm. + focal_height: Required by the abstract :class:`LuminescenceBackend` + contract but **ignored on the Byonoy L96** — the device has a + fixed optical configuration (the detector unit clamps onto the + base; the optical path is determined by plate + base + detector + geometry, not user-tunable). Passing any value is harmless; + passing 0 is conventional. backend_params: Backend-specific parameters. """ if not isinstance(backend_params, self.LuminescenceParams):