From cdda6bf08f576928d0c89b4e0995bbdb9e9169db Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Tue, 5 May 2026 14:06:41 +0200 Subject: [PATCH 01/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] =?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/28] 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/28] 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): From 94c9b94070a5ae25fc1ff9b948dd881cb0fae5e3 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 18:01:57 -0700 Subject: [PATCH 15/28] Fix LED packing; collapse set_led_colours/set_led_effect into set_led(colors, effect) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes, entangled: 1. Bug fix in _set_led_effect packing. Per vendor byonoyusbhid.h (enforce_offsets_led_bar_effects_out_t static_asserts) the struct is: effect:u8 color:(r,g,b u8) effect_state:u8 flags:u8 duration:u32 The PR was dropping the 3-byte color field, so every later byte was shifted -3 and FLAG_LED_MANUAL was never actually set (firmware was reading effect_state=our flags byte, flags=byte 0 of duration_ms = 0). This made the "switch to manual SOLID" preamble a no-op, leaving the firmware free to keep animating. Also adds FLAG_LED_FORCE (0x10) — the vendor uses MANUAL|AUTOFLIP|FORCE during fwupdate to override idle state. 2. Public API: collapse set_led_colours(colours) + set_led_effect(effect, ...) into set_led(colors, effect=SOLID, *, effect_state=0, duration_ms=0). _set_led_effect kept as private escape hatch for "release to firmware default" (manual=False). Renames colour -> color (American spelling). Verified on hardware: - HID write returns 65 (1 prefix + 64 payload, matches vendor's sizeof(hid_report_t)<=64). - Wire bytes match vendor struct byte-for-byte. - Demo on standalone L96 (BYOMML00066) shows the report is accepted (get_status still responsive after) but no visible change — that SKU appears to lack the RGB bar present on the L96A. Needs L96A for visual validation of the visible-behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/backend.py | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 56d1af2e92a..aa04e4611e2 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -422,15 +422,25 @@ async def cancel(self, report_id: int = 0x0340) -> None: 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). + async def set_led( + self, + colors: List[Tuple[int, int, int]], + effect: LedEffect = LedEffect.SOLID, + *, + effect_state: int = 0, + duration_ms: int = 0, + ) -> None: + """Set the 20-LED bar via REP_LED_BAR_COLOURS_OUT (0x0350). - 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. + First switches the bar into manual mode (FLAG_LED_MANUAL | FLAG_LED_FORCE) + under the requested `effect` so the firmware doesn't overwrite the colors + 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)) + base = colors[0] if colors else (0, 0, 0) + await self._set_led_effect(effect, color=base, manual=True, + effect_state=effect_state, duration_ms=duration_ms) + pixels = list(colors[:20]) + [(0, 0, 0)] * max(0, 20 - len(colors)) w = Writer() for r_, g, b in pixels: w.u8(r_ & 0xFF).u8(g & 0xFF).u8(b & 0xFF) @@ -438,22 +448,26 @@ async def set_led_colours(self, colours: List[Tuple[int, int, int]]) -> None: report_id=0x0350, payload=w.finish(), wait_for_response=False ) - async def set_led_effect( + async def _set_led_effect( self, effect: LedEffect, + color: Tuple[int, int, int] = (0, 0, 0), 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`. + Packed layout (vendor byonoyusbhid.h led_bar_effects_out_t): + effect:u8 color:(r,g,b u8) effect_state:u8 flags:u8 duration_ms:u32 """ - flags = 0x02 if manual else 0 # FLAG_LED_MANUAL + # FLAG_LED_MANUAL=0x02, FLAG_LED_FORCE=0x10 — force overrides idle state. + flags = (0x02 | 0x10) if manual else 0 + r_, g, b = color payload = ( Writer() .u8(int(effect)) + .u8(r_ & 0xFF).u8(g & 0xFF).u8(b & 0xFF) .u8(effect_state & 0xFF) .u8(flags) .u32(int(duration_ms)) From 40429a4261ec0057978e42967042b6447539efc0 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 18:02:08 -0700 Subject: [PATCH 16/28] docs: update LED examples and arch notes for new set_led API Section 10 + the section-11 luciferase recipe now use set_led(colors, effect=...) with a breathing-over-palette example; the "release to firmware default" path uses the _set_led_effect escape hatch directly so readers see how to hand control back. ARCHITECTURE_NOTES.md F1 reflects the collapsed surface (the proposed ByonoyLEDBar helper now exposes one set(colors, effect=...) instead of two). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../byonoy/luminescence_96/lab_guide.ipynb | 310 ++++++++++++------ pylabrobot/byonoy/ARCHITECTURE_NOTES.md | 18 +- 2 files changed, 213 insertions(+), 115 deletions(-) diff --git a/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb b/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb index f7f09e8495e..62a4211d6fa 100644 --- a/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb +++ b/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb @@ -1,40 +1,73 @@ { "cells": [ - {"cell_type": "markdown", "id": "intro", "metadata": {}, "source": [ + { + "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": [ + ] + }, + { + "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": [ + ] + }, + { + "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": [ + ] + }, + { + "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": [ + ] + }, + { + "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": [ + ] + }, + { + "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", @@ -48,8 +81,15 @@ "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": [ + ] + }, + { + "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", @@ -57,8 +97,13 @@ "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": [ + ] + }, + { + "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", @@ -69,8 +114,15 @@ "| `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": [ + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s4-code", + "metadata": {}, + "outputs": [], + "source": [ "from pylabrobot.byonoy import ByonoyLuminescence96Backend, Lum96IntegrationMode\n", "\n", "# Preset\n", @@ -90,15 +142,27 @@ " integration_time=5.0,\n", " ),\n", ")" - ]}, - {"cell_type": "markdown", "id": "s5-md", "metadata": {}, "source": [ + ] + }, + { + "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": [ + ] + }, + { + "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", @@ -111,25 +175,49 @@ " selected_wells=mask,\n", " ),\n", ")" - ]}, - {"cell_type": "markdown", "id": "s6-md", "metadata": {}, "source": [ + ] + }, + { + "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": [ + ] + }, + { + "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": [ + ] + }, + { + "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": [ + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s7-code", + "metadata": {}, + "outputs": [], + "source": [ "import asyncio, time\n", "import numpy as np\n", "\n", @@ -154,13 +242,25 @@ "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": [ + ] + }, + { + "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": [ + ] + }, + { + "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", @@ -174,8 +274,13 @@ " await task\n", "except asyncio.CancelledError:\n", " print(\"aborted cleanly\")" - ]}, - {"cell_type": "markdown", "id": "s9-md", "metadata": {}, "source": [ + ] + }, + { + "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", @@ -183,8 +288,15 @@ "> **`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": [ + ] + }, + { + "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", @@ -197,72 +309,45 @@ "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": [ + ] + }, + { + "cell_type": "markdown", + "id": "s10-md", + "metadata": {}, + "source": "## 10. Visual feedback (LED bar)\n\nThe 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` 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 color — auto-enables manual mode\nawait reader.driver.set_led([(255, 200, 0)] * 20) # amber: queued\nawait reader.driver.set_led([(0, 255, 0)] * 20) # green: ready\nawait reader.driver.set_led([(255, 0, 0)] * 20) # red: error\n\n# Dynamic effects animate over the supplied palette\nawait reader.driver.set_led([(0, 255, 0)] * 20, effect=LedEffect.BREATHING, duration_ms=10000)\n\n# Release the bar back to the firmware's default animation\nawait reader.driver._set_led_effect(LedEffect.SOLID, duration_ms=0)" + }, + { + "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": [ + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s11-code", + "metadata": {}, + "outputs": [], + "source": "import asyncio, time\nimport numpy as np\nfrom pylabrobot.byonoy import (\n byonoy_l96, ByonoyLuminescence96Backend,\n Lum96IntegrationMode, LedEffect,\n)\nfrom pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\n# Connect\nbase, reader = byonoy_l96(name=\"assay\")\nawait reader.setup()\nawait reader.driver.set_led([(255, 150, 0)] * 20) # amber: prep\n\n# Sanity check\nstatus = await reader.driver.get_status()\ninfo = await reader.driver.get_device_info()\nprint(f\"{info.device_name} sn={info.serial_no} — {status.slot_state.name}\")\nassert status.error_code == 0\n\n# Load plate\nbase.reader_unit_holder.unassign_child_resource(reader)\nplate = Cor_96_wellplate_360ul_Fb(name=\"assay_plate\")\nbase.plate_holder.assign_child_resource(plate)\n# (operator places plate, places detector back on top)\n\n# Read — green while measuring\nawait reader.driver.set_led([(0, 255, 0)] * 20)\nresults = await reader.luminescence.read(\n plate=plate,\n focal_height=0,\n backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n mode=Lum96IntegrationMode.SENSITIVE,\n ),\n)\ndata = np.array(results[0].data) # 8 × 12\n\n# Save + tidy up\nnp.save(f\"luminescence_{int(time.time())}.npy\", data)\nawait reader.driver._set_led_effect(LedEffect.SOLID, duration_ms=0)\nawait reader.stop()" + }, + { + "cell_type": "markdown", + "id": "s12-md", + "metadata": {}, + "source": [ "## 12. Troubleshooting\n", "\n", "| Symptom | Likely cause | Fix |\n", @@ -275,20 +360,33 @@ "| `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": [ + ] + }, + { + "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"} + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/pylabrobot/byonoy/ARCHITECTURE_NOTES.md b/pylabrobot/byonoy/ARCHITECTURE_NOTES.md index 58b98a087e5..330376ee959 100644 --- a/pylabrobot/byonoy/ARCHITECTURE_NOTES.md +++ b/pylabrobot/byonoy/ARCHITECTURE_NOTES.md @@ -54,10 +54,10 @@ When a future PR refactors: ### 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`, +`set_led` (public) and `_set_led_effect` (private helper) live as flat +methods on the `Driver`. They form a coherent subsystem (touch reports +0x0350 / 0x0351, share manual-mode coordination — `set_led` already +chains an effect-set + color-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. @@ -72,12 +72,12 @@ class ByonoyLEDBar: 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: ... + async def set(self, colors: List[Tuple[int, int, int]], + effect: LedEffect = LedEffect.SOLID, ...) -> None: ... ``` -User call site changes from `reader.driver.set_led_colours(...)` to -`reader.driver.led_bar.set_colours(...)`. +User call site changes from `reader.driver.set_led(...)` to +`reader.driver.led_bar.set(...)`. ### F2 — Device-info queries could be a P-16 helper subsystem (soft) @@ -147,7 +147,7 @@ rename to `ByonoyDriver`. The per-device pid is already passed via `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 + (`driver.led_bar.set` vs `driver.set_led`); worth doing in a single coordinated PR rather than piecemeal. ## Reference From 255b8d69527d891b89e9c261f0ad3ea77540868a Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 18:19:31 -0700 Subject: [PATCH 17/28] LED: warn once on non-Automate SKUs (serial prefix heuristic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the standalone L96 (BYOMML*) and the Automate L96A (BYOMAL*) share the same USB PID 0x119B, the same firmware build, and the same default ref_number "DE MML 001" (per vendor byonoy_device_library DEFAULT_LUM_96_REF_NO) — so firmware version / ref_number / PID can't distinguish them. Empirically the firmware accepts the LED reports on both, but only the Automate hardware appears to have the physical 20-pixel RGB bar; on the standalone L96 the writes succeed silently with no visible effect. Add a one-time logged warning when set_led / _set_led_effect are called on a device whose serial doesn't start with "BYOMAL". Heuristic, not a guarantee — kept as a warning (not an error) so future SKUs that happen to have the bar aren't blocked. Lab guide section 10 gains a callout explaining the SKU caveat and pointing at the warning. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../byonoy/luminescence_96/lab_guide.ipynb | 65 +++++++++++++++++-- pylabrobot/byonoy/backend.py | 36 ++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb b/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb index 62a4211d6fa..90761a172d6 100644 --- a/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb +++ b/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb @@ -315,7 +315,7 @@ "cell_type": "markdown", "id": "s10-md", "metadata": {}, - "source": "## 10. Visual feedback (LED bar)\n\nThe 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` is the precise way to control exactly what you see." + "source": "## 10. Visual feedback (LED bar)\n\nThe L96A (Automate variant) 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` is the precise way to control exactly what you see.\n\n> **SKU caveat.** The standalone L96 (serial prefix `BYOMML`) shares the same USB PID, firmware build, and HID protocol as the Automate L96A (serial prefix `BYOMAL`), but the standalone hardware appears to lack the physical RGB bar. The firmware accepts the LED reports either way, so writes silently succeed but produce no visible change. The driver logs a one-time warning when LED methods are called on a non-`BYOMAL` device. Visual validation requires an L96A; this PR was validated on serial `BYOMAL00029`." }, { "cell_type": "code", @@ -323,7 +323,20 @@ "id": "s10-code", "metadata": {}, "outputs": [], - "source": "from pylabrobot.byonoy import LedEffect\n\n# Solid color — auto-enables manual mode\nawait reader.driver.set_led([(255, 200, 0)] * 20) # amber: queued\nawait reader.driver.set_led([(0, 255, 0)] * 20) # green: ready\nawait reader.driver.set_led([(255, 0, 0)] * 20) # red: error\n\n# Dynamic effects animate over the supplied palette\nawait reader.driver.set_led([(0, 255, 0)] * 20, effect=LedEffect.BREATHING, duration_ms=10000)\n\n# Release the bar back to the firmware's default animation\nawait reader.driver._set_led_effect(LedEffect.SOLID, duration_ms=0)" + "source": [ + "from pylabrobot.byonoy import LedEffect\n", + "\n", + "# Solid color — auto-enables manual mode\n", + "await reader.driver.set_led([(255, 200, 0)] * 20) # amber: queued\n", + "await reader.driver.set_led([(0, 255, 0)] * 20) # green: ready\n", + "await reader.driver.set_led([(255, 0, 0)] * 20) # red: error\n", + "\n", + "# Dynamic effects animate over the supplied palette\n", + "await reader.driver.set_led([(0, 255, 0)] * 20, effect=LedEffect.BREATHING, duration_ms=10000)\n", + "\n", + "# Release the bar back to the firmware's default animation\n", + "await reader.driver._set_led_effect(LedEffect.SOLID, duration_ms=0)" + ] }, { "cell_type": "markdown", @@ -341,7 +354,48 @@ "id": "s11-code", "metadata": {}, "outputs": [], - "source": "import asyncio, time\nimport numpy as np\nfrom pylabrobot.byonoy import (\n byonoy_l96, ByonoyLuminescence96Backend,\n Lum96IntegrationMode, LedEffect,\n)\nfrom pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\n# Connect\nbase, reader = byonoy_l96(name=\"assay\")\nawait reader.setup()\nawait reader.driver.set_led([(255, 150, 0)] * 20) # amber: prep\n\n# Sanity check\nstatus = await reader.driver.get_status()\ninfo = await reader.driver.get_device_info()\nprint(f\"{info.device_name} sn={info.serial_no} — {status.slot_state.name}\")\nassert status.error_code == 0\n\n# Load plate\nbase.reader_unit_holder.unassign_child_resource(reader)\nplate = Cor_96_wellplate_360ul_Fb(name=\"assay_plate\")\nbase.plate_holder.assign_child_resource(plate)\n# (operator places plate, places detector back on top)\n\n# Read — green while measuring\nawait reader.driver.set_led([(0, 255, 0)] * 20)\nresults = await reader.luminescence.read(\n plate=plate,\n focal_height=0,\n backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n mode=Lum96IntegrationMode.SENSITIVE,\n ),\n)\ndata = np.array(results[0].data) # 8 × 12\n\n# Save + tidy up\nnp.save(f\"luminescence_{int(time.time())}.npy\", data)\nawait reader.driver._set_led_effect(LedEffect.SOLID, duration_ms=0)\nawait reader.stop()" + "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([(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([(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", @@ -369,10 +423,7 @@ "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." + "- **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." ] } ], diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index aa04e4611e2..41bf1985902 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -192,6 +192,7 @@ def __init__(self, pid: int, device_type: ByonoyDevice) -> None: self._sending_pings = False self._device_type = device_type self._abort_requested = False + self._led_bar_warning_issued = False async def setup(self, backend_params: Optional[BackendParams] = None) -> None: await self.io.setup() @@ -422,6 +423,33 @@ async def cancel(self, report_id: int = 0x0340) -> None: 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 _warn_if_likely_no_led_bar(self) -> None: + """Log a one-time warning if this device is likely a non-Automate SKU + without a physical RGB bar. Heuristic: serial number prefix. + + Both the standalone L96 ("BYOMML*") and the Automate L96A ("BYOMAL*") + share the same USB PID, firmware build, and HID protocol — the reports + are accepted on both, but the standalone hardware appears to lack the + 20-pixel RGB bar, so the writes have no visible effect. Tested only on + the Automate (PR #1027 was validated on serial BYOMAL00029). + """ + if self._led_bar_warning_issued: + return + self._led_bar_warning_issued = True + try: + info = await self.get_device_info() + except Exception: + return + if not info.serial_no.startswith("BYOMAL"): + logger.warning( + "[Byonoy] LED bar writes may have no visible effect on this device " + "(serial=%s). The 20-pixel RGB bar has only been validated on the " + "Automate variant (serial prefix 'BYOMAL'). Standalone L96 ('BYOMML') " + "hardware appears not to have the bar; firmware accepts the report " + "either way.", + info.serial_no, + ) + async def set_led( self, colors: List[Tuple[int, int, int]], @@ -436,7 +464,12 @@ async def set_led( under the requested `effect` so the firmware doesn't overwrite the colors with its own animation, then sends the 20-pixel buffer. Pads with black if fewer than 20 are given. + + Visible only on Automate-variant hardware (serial prefix "BYOMAL"); the + standalone L96 ("BYOMML") accepts the reports but appears to lack the + physical RGB bar. A one-time warning is logged on non-Automate devices. """ + await self._warn_if_likely_no_led_bar() base = colors[0] if colors else (0, 0, 0) await self._set_led_effect(effect, color=base, manual=True, effect_state=effect_state, duration_ms=duration_ms) @@ -460,7 +493,10 @@ async def _set_led_effect( Packed layout (vendor byonoyusbhid.h led_bar_effects_out_t): effect:u8 color:(r,g,b u8) effect_state:u8 flags:u8 duration_ms:u32 + + See `set_led` for the SKU caveat — visible only on Automate hardware. """ + await self._warn_if_likely_no_led_bar() # FLAG_LED_MANUAL=0x02, FLAG_LED_FORCE=0x10 — force overrides idle state. flags = (0x02 | 0x10) if manual else 0 r_, g, b = color From 767b41b6f54d1bf552b13279eef65efe3c95a4f9 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 20:01:14 -0700 Subject: [PATCH 18/28] LED: route writes with PC tag; split set_led into set_led_color/set_led_colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware silently drops LED reports (0x0350/0x0351) that arrive with the default LEGACY routing tag, so the bar was never actually painted on any SKU. Pass request_info=0x4000 (PC) on both writes. This supersedes the SKU-prefix warning from 255b8d695, which was diagnosing the routing bug as missing hardware on standalone L96 ('BYOMML') devices. With per-pixel writes (0x0350) now functional, split the API back into the two methods that mirror the vendor library's report split: set_led_color(color, effect=SOLID, *, low_power, force, duration_ms) drives REP_LED_BAR_EFFECTS_OUT (0x0351) — single color, optionally animated by firmware (BREATHING, CYLON, RAINBOW, ...). set_led_colors(colors) drives REP_LED_BAR_COLOURS_OUT (0x0350) — up to 20 RGB triplets, padded/truncated. Fast enough for ~30 fps. Lab guide §10 shrinks to a teaser pointing at the new led_bar.ipynb, which covers the full surface (solid, effects, per-pixel, KITT scanner, walking dot). Co-Authored-By: Claude Opus 4.7 --- .../byonoy/luminescence_96/lab_guide.ipynb | 60 +--- .../byonoy/luminescence_96/led_bar.ipynb | 327 ++++++++++++++++++ pylabrobot/byonoy/ARCHITECTURE_NOTES.md | 11 +- pylabrobot/byonoy/backend.py | 100 ++---- 4 files changed, 369 insertions(+), 129 deletions(-) create mode 100644 docs/user_guide/byonoy/luminescence_96/led_bar.ipynb diff --git a/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb b/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb index 90761a172d6..b0c7653e35a 100644 --- a/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb +++ b/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb @@ -315,7 +315,7 @@ "cell_type": "markdown", "id": "s10-md", "metadata": {}, - "source": "## 10. Visual feedback (LED bar)\n\nThe L96A (Automate variant) 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` is the precise way to control exactly what you see.\n\n> **SKU caveat.** The standalone L96 (serial prefix `BYOMML`) shares the same USB PID, firmware build, and HID protocol as the Automate L96A (serial prefix `BYOMAL`), but the standalone hardware appears to lack the physical RGB bar. The firmware accepts the LED reports either way, so writes silently succeed but produce no visible change. The driver logs a one-time warning when LED methods are called on a non-`BYOMAL` device. Visual validation requires an L96A; this PR was validated on serial `BYOMAL00029`." + "source": "## 10. Visual feedback (LED bar)\n\nThe L96 has a 20-pixel addressable RGB front bar. Useful in a workcell to flag run state — solid colors for status, firmware-driven animations (`BREATHING`, `CYLON`, etc.) for \"busy\" indicators, or per-pixel control for progress bars and custom animations.\n\nSee the dedicated [LED bar notebook](led_bar.ipynb) for the full surface and recipes (KITT scanner, gradients, etc.). Quick taste below." }, { "cell_type": "code", @@ -323,20 +323,7 @@ "id": "s10-code", "metadata": {}, "outputs": [], - "source": [ - "from pylabrobot.byonoy import LedEffect\n", - "\n", - "# Solid color — auto-enables manual mode\n", - "await reader.driver.set_led([(255, 200, 0)] * 20) # amber: queued\n", - "await reader.driver.set_led([(0, 255, 0)] * 20) # green: ready\n", - "await reader.driver.set_led([(255, 0, 0)] * 20) # red: error\n", - "\n", - "# Dynamic effects animate over the supplied palette\n", - "await reader.driver.set_led([(0, 255, 0)] * 20, effect=LedEffect.BREATHING, duration_ms=10000)\n", - "\n", - "# Release the bar back to the firmware's default animation\n", - "await reader.driver._set_led_effect(LedEffect.SOLID, duration_ms=0)" - ] + "source": "from pylabrobot.byonoy import LedEffect\n\nawait reader.driver.set_led_color((0, 255, 0)) # solid green: ready\nawait reader.driver.set_led_color((0, 255, 0), LedEffect.BREATHING, duration_ms=10000) # busy\nawait reader.driver.set_led_colors([(255, 0, 0)] * 10 + [(0, 0, 255)] * 10) # per-pixel split" }, { "cell_type": "markdown", @@ -354,48 +341,7 @@ "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([(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([(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()" - ] + "source": "import asyncio, time\nimport numpy as np\nfrom pylabrobot.byonoy import (\n byonoy_l96, ByonoyLuminescence96Backend,\n Lum96IntegrationMode, LedEffect,\n)\nfrom pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\n# Connect\nbase, reader = byonoy_l96(name=\"assay\")\nawait reader.setup()\nawait reader.driver.set_led_color((255, 150, 0)) # amber: prep\n\n# Sanity check\nstatus = await reader.driver.get_status()\ninfo = await reader.driver.get_device_info()\nprint(f\"{info.device_name} sn={info.serial_no} — {status.slot_state.name}\")\nassert status.error_code == 0\n\n# Load plate\nbase.reader_unit_holder.unassign_child_resource(reader)\nplate = Cor_96_wellplate_360ul_Fb(name=\"assay_plate\")\nbase.plate_holder.assign_child_resource(plate)\n# (operator places plate, places detector back on top)\n\n# Read — green while measuring\nawait reader.driver.set_led_color((0, 255, 0))\nresults = await reader.luminescence.read(\n plate=plate,\n focal_height=0,\n backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n mode=Lum96IntegrationMode.SENSITIVE,\n ),\n)\ndata = np.array(results[0].data) # 8 × 12\n\n# Save + tidy up\nnp.save(f\"luminescence_{int(time.time())}.npy\", data)\nawait reader.driver.set_led_color((0, 0, 0)) # off\nawait reader.stop()" }, { "cell_type": "markdown", diff --git a/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb b/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb new file mode 100644 index 00000000000..a2c5fad7ec0 --- /dev/null +++ b/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb @@ -0,0 +1,327 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Byonoy L96 — LED bar\n", + "\n", + "The L96 has a 20-pixel addressable RGB front bar on the chassis. PyLabRobot exposes two methods to drive it:\n", + "\n", + "| Method | Use for |\n", + "|---|---|\n", + "| `set_led_color(color, effect)` | Single color across all 20 pixels, optionally animated by the firmware (`BREATHING`, `CYLON`, `RAINBOW`, ...) |\n", + "| `set_led_colors(colors)` | Per-pixel control — supply a list of up to 20 RGB triplets. Fast enough for real-time animation. |" + ] + }, + { + "cell_type": "markdown", + "id": "connect-md", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "We'll talk to the device's driver directly, so the bar can be driven without setting up a plate read." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "connect-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:03.229447Z", + "iopub.status.busy": "2026-05-17T02:44:03.229245Z", + "iopub.status.idle": "2026-05-17T02:44:03.307144Z", + "shell.execute_reply": "2026-05-17T02:44:03.306740Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-05-16 19:53:20,899 - pylabrobot.byonoy.backend - INFO - [Byonoy LUMINESCENCE_96 pid=0x119B] connected\n" + ] + } + ], + "source": [ + "from pylabrobot.byonoy import byonoy_l96, LedEffect\n", + "\n", + "base, reader = byonoy_l96(name=\"l96\")\n", + "await reader.setup()\n", + "drv = reader.driver" + ] + }, + { + "cell_type": "markdown", + "id": "solid-md", + "metadata": {}, + "source": [ + "## Solid colors\n", + "\n", + "The simplest call: one color, `SOLID` effect (the default). The firmware snaps the bar to that color." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "solid-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:03.308426Z", + "iopub.status.busy": "2026-05-17T02:44:03.308329Z", + "iopub.status.idle": "2026-05-17T02:44:06.321067Z", + "shell.execute_reply": "2026-05-17T02:44:06.319852Z" + } + }, + "outputs": [], + "source": [ + "import asyncio\n", + "\n", + "await drv.set_led_color((255, 0, 0)) # red\n", + "await asyncio.sleep(1)\n", + "await drv.set_led_color((0, 255, 0)) # green\n", + "await asyncio.sleep(1)\n", + "await drv.set_led_color((0, 0, 255)) # blue\n", + "await asyncio.sleep(1)\n", + "await drv.set_led_color((0, 0, 0)) # off" + ] + }, + { + "cell_type": "markdown", + "id": "effects-md", + "metadata": {}, + "source": [ + "## Built-in effects\n", + "\n", + "The firmware can animate the base color for you. Pass `duration_ms` to set how long the effect runs before reverting to firmware control. Use `force=True` to override an unexpired previous duration.\n", + "\n", + "| Effect | Behavior |\n", + "|---|---|\n", + "| `SOLID` | Snap to color (default) |\n", + "| `BREATHING` | Pulse brightness |\n", + "| `BLINKING` | Flash on/off |\n", + "| `CYLON` | Bouncing dot across the bar |\n", + "| `RAINBOW` | Cycle through hues (ignores supplied color) |\n", + "| `PROGRESS` | Fill progressively, driven by `effect_state` (0–255) |" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "effects-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:06.324515Z", + "iopub.status.busy": "2026-05-17T02:44:06.324197Z", + "iopub.status.idle": "2026-05-17T02:44:16.339410Z", + "shell.execute_reply": "2026-05-17T02:44:16.337534Z" + } + }, + "outputs": [], + "source": [ + "await drv.set_led_color((0, 255, 0), LedEffect.BREATHING, duration_ms=6000)\n", + "await asyncio.sleep(6)\n", + "\n", + "await drv.set_led_color((255, 0, 255), LedEffect.CYLON, duration_ms=4000, force=True)\n", + "await asyncio.sleep(4)\n", + "\n", + "await drv.set_led_color((0, 0, 0)) # back to off" + ] + }, + { + "cell_type": "markdown", + "id": "pixels-md", + "metadata": {}, + "source": [ + "## Per-pixel control\n", + "\n", + "`set_led_colors(colors)` takes a list of up to 20 `(r, g, b)` triplets, one per pixel (left to right). Shorter lists are zero-padded; longer ones are truncated." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "pixels-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:16.344037Z", + "iopub.status.busy": "2026-05-17T02:44:16.343628Z", + "iopub.status.idle": "2026-05-17T02:44:23.366202Z", + "shell.execute_reply": "2026-05-17T02:44:23.364702Z" + } + }, + "outputs": [], + "source": [ + "# Left half red, right half blue\n", + "await drv.set_led_colors([(255, 0, 0)] * 10 + [(0, 0, 255)] * 10)\n", + "await asyncio.sleep(2)\n", + "\n", + "# Rainbow gradient across the bar\n", + "import colorsys\n", + "def hue(i, n=20):\n", + " r, g, b = colorsys.hsv_to_rgb(i / n, 1, 1)\n", + " return (int(r * 255), int(g * 255), int(b * 255))\n", + "\n", + "await drv.set_led_colors([hue(i) for i in range(20)])\n", + "await asyncio.sleep(3)\n", + "\n", + "# Every other pixel\n", + "await drv.set_led_colors([(0, 255, 0) if i % 2 == 0 else (0, 0, 0) for i in range(20)])\n", + "await asyncio.sleep(2)\n", + "\n", + "await drv.set_led_colors([(0, 0, 0)] * 20) # off" + ] + }, + { + "cell_type": "markdown", + "id": "scanner-md", + "metadata": {}, + "source": [ + "## Animation recipe: KITT scanner\n", + "\n", + "A back-and-forth scanner with a fading trail — the kind of thing you'd want as a \"robot is busy\" indicator. Frame rate is set by how fast you call `set_led_colors`; ~30 fps is comfortable." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "scanner-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:23.369015Z", + "iopub.status.busy": "2026-05-17T02:44:23.368873Z", + "iopub.status.idle": "2026-05-17T02:44:30.548475Z", + "shell.execute_reply": "2026-05-17T02:44:30.547235Z" + } + }, + "outputs": [], + "source": [ + "import asyncio\n", + "\n", + "N = 20\n", + "trail = 4 # length of the fading trail\n", + "decay = 0.45 # brightness ratio per trail step\n", + "step_s = 0.06 # ~16 fps; lower for faster\n", + "\n", + "def frame(head):\n", + " px = [(0, 0, 0)] * N\n", + " for k in range(trail + 1):\n", + " v = int(255 * (decay ** k))\n", + " if 0 <= head - k < N:\n", + " px[head - k] = (v, 0, 0) # red trail\n", + " return px\n", + "\n", + "positions = list(range(N)) + list(range(N - 2, 0, -1)) # 0..19..1\n", + "for _ in range(3): # 3 ping-pong cycles\n", + " for head in positions:\n", + " await drv.set_led_colors(frame(head))\n", + " await asyncio.sleep(step_s)\n", + "\n", + "await drv.set_led_colors([(0, 0, 0)] * 20)" + ] + }, + { + "cell_type": "markdown", + "id": "walking-md", + "metadata": {}, + "source": [ + "## Animation recipe: walking dot\n", + "\n", + "Simplest possible animation — one bright pixel marches across the bar. Useful as a progress indicator with a known step count." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "walking-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:30.551351Z", + "iopub.status.busy": "2026-05-17T02:44:30.551132Z", + "iopub.status.idle": "2026-05-17T02:44:33.613534Z", + "shell.execute_reply": "2026-05-17T02:44:33.612076Z" + } + }, + "outputs": [], + "source": [ + "for pos in range(20):\n", + " px = [(0, 0, 0)] * 20\n", + " px[pos] = (0, 255, 0)\n", + " await drv.set_led_colors(px)\n", + " await asyncio.sleep(0.15)\n", + "\n", + "await drv.set_led_colors([(0, 0, 0)] * 20)" + ] + }, + { + "cell_type": "markdown", + "id": "teardown-md", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "teardown-code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-17T02:44:33.616454Z", + "iopub.status.busy": "2026-05-17T02:44:33.616208Z", + "iopub.status.idle": "2026-05-17T02:44:33.621351Z", + "shell.execute_reply": "2026-05-17T02:44:33.620597Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-05-16 19:53:51,258 - pylabrobot.byonoy.backend - INFO - [Byonoy LUMINESCENCE_96 pid=0x119B] disconnected\n" + ] + } + ], + "source": [ + "await drv.set_led_color((0, 0, 0)) # ensure bar is off\n", + "await reader.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "reference", + "metadata": {}, + "source": [ + "## Reference\n", + "\n", + "- `set_led_color(color, effect, *, duration_ms=0, force=False, low_power=False)` — single uniform color, optional firmware-driven effect.\n", + "- `set_led_colors(colors)` — list of up to 20 `(r, g, b)` triplets, one per pixel. Pads with black; truncates if longer.\n", + "- Both methods live on `reader.driver`. Source: `pylabrobot/byonoy/backend.py`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pylabrobot/byonoy/ARCHITECTURE_NOTES.md b/pylabrobot/byonoy/ARCHITECTURE_NOTES.md index 330376ee959..7a77f32211c 100644 --- a/pylabrobot/byonoy/ARCHITECTURE_NOTES.md +++ b/pylabrobot/byonoy/ARCHITECTURE_NOTES.md @@ -54,10 +54,9 @@ When a future PR refactors: ### F1 — LED control could be a P-16 helper subsystem (soft) -`set_led` (public) and `_set_led_effect` (private helper) live as flat -methods on the `Driver`. They form a coherent subsystem (touch reports -0x0350 / 0x0351, share manual-mode coordination — `set_led` already -chains an effect-set + color-write). v1b1 precedent: `STARCover`, +`set_led` lives as a flat method on the `Driver`. It mirrors the vendor +`set_led_effect(effect, color, modes, ...)` API one-for-one (single +REP_LED_BAR_EFFECTS_OUT report). v1b1 precedent: `STARCover`, `STARWashStation`, `NimbusDoor` group related operations into a plain helper class attached as a Driver attribute, with `_on_setup` / `_on_stop` hooks. @@ -67,12 +66,12 @@ Suggested shape: ```python class ByonoyLEDBar: """Plain helper class (not a CapabilityBackend), following the - STARCover pattern. Drives the 20-pixel front bar.""" + STARCover pattern.""" 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(self, colors: List[Tuple[int, int, int]], + async def set(self, color: Tuple[int, int, int], effect: LedEffect = LedEffect.SOLID, ...) -> None: ... ``` diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 41bf1985902..5bd5ebbf044 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -192,7 +192,6 @@ def __init__(self, pid: int, device_type: ByonoyDevice) -> None: self._sending_pings = False self._device_type = device_type self._abort_requested = False - self._led_bar_warning_issued = False async def setup(self, backend_params: Optional[BackendParams] = None) -> None: await self.io.setup() @@ -423,82 +422,33 @@ async def cancel(self, report_id: int = 0x0340) -> None: 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 _warn_if_likely_no_led_bar(self) -> None: - """Log a one-time warning if this device is likely a non-Automate SKU - without a physical RGB bar. Heuristic: serial number prefix. - - Both the standalone L96 ("BYOMML*") and the Automate L96A ("BYOMAL*") - share the same USB PID, firmware build, and HID protocol — the reports - are accepted on both, but the standalone hardware appears to lack the - 20-pixel RGB bar, so the writes have no visible effect. Tested only on - the Automate (PR #1027 was validated on serial BYOMAL00029). - """ - if self._led_bar_warning_issued: - return - self._led_bar_warning_issued = True - try: - info = await self.get_device_info() - except Exception: - return - if not info.serial_no.startswith("BYOMAL"): - logger.warning( - "[Byonoy] LED bar writes may have no visible effect on this device " - "(serial=%s). The 20-pixel RGB bar has only been validated on the " - "Automate variant (serial prefix 'BYOMAL'). Standalone L96 ('BYOMML') " - "hardware appears not to have the bar; firmware accepts the report " - "either way.", - info.serial_no, - ) - - async def set_led( + async def set_led_color( self, - colors: List[Tuple[int, int, int]], + color: Tuple[int, int, int], effect: LedEffect = LedEffect.SOLID, *, + low_power: bool = False, + force: bool = False, effect_state: int = 0, duration_ms: int = 0, ) -> None: - """Set the 20-LED bar via REP_LED_BAR_COLOURS_OUT (0x0350). - - First switches the bar into manual mode (FLAG_LED_MANUAL | FLAG_LED_FORCE) - under the requested `effect` so the firmware doesn't overwrite the colors - with its own animation, then sends the 20-pixel buffer. Pads with black if - fewer than 20 are given. - - Visible only on Automate-variant hardware (serial prefix "BYOMAL"); the - standalone L96 ("BYOMML") accepts the reports but appears to lack the - physical RGB bar. A one-time warning is logged on non-Automate devices. - """ - await self._warn_if_likely_no_led_bar() - base = colors[0] if colors else (0, 0, 0) - await self._set_led_effect(effect, color=base, manual=True, - effect_state=effect_state, duration_ms=duration_ms) - pixels = list(colors[:20]) + [(0, 0, 0)] * max(0, 20 - len(colors)) - 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 - ) + """Set the LED bar to a single color via REP_LED_BAR_EFFECTS_OUT (0x0351). - async def _set_led_effect( - self, - effect: LedEffect, - color: Tuple[int, int, int] = (0, 0, 0), - effect_state: int = 0, - manual: bool = False, - duration_ms: int = 0, - ) -> None: - """Set the LED bar effect via REP_LED_BAR_EFFECTS_OUT (0x0351). + Mirrors the vendor's user-facing `set_led_effect(effect, color, modes, ...)` + in byonoy_device_library. The firmware renders `effect` over `color`: + SOLID just shows the color; BREATHING/CYLON/BLINKING/RAINBOW/PROGRESS + animate it. Packed layout (vendor byonoyusbhid.h led_bar_effects_out_t): effect:u8 color:(r,g,b u8) effect_state:u8 flags:u8 duration_ms:u32 - See `set_led` for the SKU caveat — visible only on Automate hardware. + `force` (FLAG_LED_FORCE=0x10) overrides an unexpired previous + `duration_ms`. `low_power` (FLAG_LED_LOWPOWER=0x01) reduces brightness. + + The PC routing tag (request_info=0x4000) is required — the firmware + silently drops LED writes that arrive with the default LEGACY tag. """ - await self._warn_if_likely_no_led_bar() - # FLAG_LED_MANUAL=0x02, FLAG_LED_FORCE=0x10 — force overrides idle state. - flags = (0x02 | 0x10) if manual else 0 + flags = (0x01 if low_power else 0) | (0x10 if force else 0) r_, g, b = color payload = ( Writer() @@ -510,7 +460,25 @@ async def _set_led_effect( .finish() ) await self.send_command( - report_id=0x0351, payload=payload, wait_for_response=False + report_id=0x0351, payload=payload, wait_for_response=False, + routing_info=b"\x00\x40", + ) + + async def set_led_colors(self, colors: List[Tuple[int, int, int]]) -> None: + """Set each of the 20 LED bar pixels individually via + REP_LED_BAR_COLOURS_OUT (0x0350). Pads with black if fewer than 20 are + given; truncates if more. Fast enough for real-time animation (~30+ fps). + + Like `set_led_color`, requires the PC routing tag (request_info=0x4000); + the firmware silently drops writes with the default LEGACY tag. + """ + pixels = list(colors[:20]) + [(0, 0, 0)] * max(0, 20 - len(colors)) + 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, + routing_info=b"\x00\x40", ) async def get_versions(self) -> ByonoyVersions: From f1044f56169a7c91924f9b13bc0c00f3d9866add Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 20:05:37 -0700 Subject: [PATCH 19/28] Rename ByonoyBase to ByonoyDriver; drop ARCHITECTURE_NOTES.md Matches the v1b1 Driver convention (STARDriver, NimbusDriver, etc.). Updates the two error strings that named the class. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/ARCHITECTURE_NOTES.md | 159 ------------------------ pylabrobot/byonoy/absorbance_96.py | 8 +- pylabrobot/byonoy/backend.py | 2 +- pylabrobot/byonoy/luminescence_96.py | 8 +- 4 files changed, 9 insertions(+), 168 deletions(-) delete mode 100644 pylabrobot/byonoy/ARCHITECTURE_NOTES.md diff --git a/pylabrobot/byonoy/ARCHITECTURE_NOTES.md b/pylabrobot/byonoy/ARCHITECTURE_NOTES.md deleted file mode 100644 index 7a77f32211c..00000000000 --- a/pylabrobot/byonoy/ARCHITECTURE_NOTES.md +++ /dev/null @@ -1,159 +0,0 @@ -# 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` lives as a flat method on the `Driver`. It mirrors the vendor -`set_led_effect(effect, color, modes, ...)` API one-for-one (single -REP_LED_BAR_EFFECTS_OUT report). 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.""" - 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(self, color: Tuple[int, int, int], - effect: LedEffect = LedEffect.SOLID, ...) -> None: ... -``` - -User call site changes from `reader.driver.set_led(...)` to -`reader.driver.led_bar.set(...)`. - -### 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` vs `driver.set_led`); 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` diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index 7bea4fc5b32..d417e4a7e77 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 ABS96_ERROR_NAMES, ByonoyBase, ByonoyDevice +from pylabrobot.byonoy.backend import ABS96_ERROR_NAMES, ByonoyDriver, ByonoyDevice from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.absorbance import ( Absorbance, @@ -26,7 +26,7 @@ # --------------------------------------------------------------------------- -class ByonoyAbsorbance96Backend(ByonoyBase, AbsorbanceBackend): +class ByonoyAbsorbance96Backend(ByonoyDriver, AbsorbanceBackend): """Backend for the Byonoy Absorbance 96 Automate plate reader.""" _ERROR_NAMES = ABS96_ERROR_NAMES @@ -255,13 +255,13 @@ def assign_child_resource( ) -> None: if isinstance(resource, _ByonoyAbsorbanceReaderPlateHolder): if self.plate_holder._byonoy_base is not None: - raise ValueError("ByonoyBase can only have one plate holder assigned.") + raise ValueError("ByonoyDriver can only have one plate holder assigned.") self.plate_holder._byonoy_base = self super().assign_child_resource(resource, location, reassign) def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: raise RuntimeError( - "ByonoyBase does not support assigning child resources directly. " + "ByonoyDriver does not support assigning child resources directly. " "Use the plate_holder or illumination_unit_holder to assign plates and the " "illumination unit, respectively." ) diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 5bd5ebbf044..cc1fc0d2020 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -174,7 +174,7 @@ class Abs1StatusError(enum.IntFlag): _ACCEL_LSB_PER_G = 16384.0 # 14-bit signed @ ±2 g full scale -class ByonoyBase(Driver, metaclass=ABCMeta): +class ByonoyDriver(Driver, metaclass=ABCMeta): """Shared HID communication logic for Byonoy plate readers.""" # Firmware error-code → name mapping. Default mirrors Byonoy's generic diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index 6f7dda0cd3e..338eb86c6cd 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -6,7 +6,7 @@ from pylabrobot.byonoy.backend import ( LUM96_PRESET_S, - ByonoyBase, + ByonoyDriver, ByonoyDevice, Lum96IntegrationMode, encode_well_bitmask, @@ -34,7 +34,7 @@ # --------------------------------------------------------------------------- -class ByonoyLuminescence96Backend(ByonoyBase, LuminescenceBackend): +class ByonoyLuminescence96Backend(ByonoyDriver, LuminescenceBackend): """Backend for the Byonoy Luminescence 96 Automate plate reader.""" def __init__(self) -> None: @@ -272,13 +272,13 @@ def assign_child_resource( ) -> None: if isinstance(resource, _ByonoyLuminescenceReaderPlateHolder): if self.plate_holder._byonoy_base is not None: - raise ValueError("ByonoyBase can only have one plate holder assigned.") + raise ValueError("ByonoyDriver can only have one plate holder assigned.") self.plate_holder._byonoy_base = self super().assign_child_resource(resource, location, reassign) def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: raise RuntimeError( - "ByonoyBase does not support assigning child resources directly. " + "ByonoyDriver does not support assigning child resources directly. " "Use the plate_holder or reader_unit_holder to assign plates and the reader unit, " "respectively." ) From 4b23107516c40781983f45937b683723db2a99c0 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 20:18:51 -0700 Subject: [PATCH 20/28] docs(byonoy/lum96): replace hello-world with lab guide Move the factory-function model table to byonoy/index.md (a more natural location for a parts-list overview), fold the resource-layout tree and Luminescence capability cross-link into the lab guide, then rename it over the old hello-world.ipynb so the toctree keeps working. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/user_guide/byonoy/index.md | 11 + .../byonoy/luminescence_96/hello-world.ipynb | 368 +++++++++++++++-- .../byonoy/luminescence_96/lab_guide.ipynb | 389 ------------------ 3 files changed, 346 insertions(+), 422 deletions(-) delete mode 100644 docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb diff --git a/docs/user_guide/byonoy/index.md b/docs/user_guide/byonoy/index.md index 3cfedb983f5..0049a510c70 100644 --- a/docs/user_guide/byonoy/index.md +++ b/docs/user_guide/byonoy/index.md @@ -6,3 +6,14 @@ absorbance_96/hello-world luminescence_96/hello-world ``` + +## Luminescence 96 models + +| Model | PLR resource | Factory function | +|---|---|---| +| L96 full setup | `ByonoyLuminescenceBaseUnit` + `ByonoyLuminescence96` | `byonoy_l96` | +| L96A full setup (automate) | `ByonoyLuminescenceBaseUnit` + `ByonoyLuminescence96` | `byonoy_l96a` | +| L96 reader unit only | `ByonoyLuminescence96` | `byonoy_l96_reader_unit` | +| L96A reader unit only | `ByonoyLuminescence96` | `byonoy_l96a_reader_unit` | +| L96 base unit only | `ByonoyLuminescenceBaseUnit` | `byonoy_l96_base_unit` | +| L96A base unit only | `ByonoyLuminescenceBaseUnit` | `byonoy_l96a_base_unit` | diff --git a/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb index d225b5e7c5d..33b26ac3124 100644 --- a/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb +++ b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb @@ -2,79 +2,381 @@ "cells": [ { "cell_type": "markdown", - "id": "ey04kywsg19", - "source": "# Byonoy Luminescence 96\n\nThe Luminescence 96 is a USB-HID plate reader from Byonoy that measures luminescence across a 96-well plate. It supports:\n\n- [Luminescence](../../capabilities/luminescence) (full-plate, configurable integration time)\n\nThe hardware consists of a **base unit** (holds the plate) and a **reader unit** (detector, sits on top during measurement). PLR models both as resources so a robotic arm can move the reader unit on and off the base. Two hardware variants exist: the L96 (manual) and L96A (automate, with a preferred pickup location for robotic handling).\n\n| Model | PLR Name | Factory function |\n|---|---|---|\n| L96 full setup | `ByonoyLuminescenceBaseUnit` + `ByonoyLuminescence96` | `byonoy_l96` |\n| L96A full setup (automate) | `ByonoyLuminescenceBaseUnit` + `ByonoyLuminescence96` | `byonoy_l96a` |\n| L96 reader unit only | `ByonoyLuminescence96` | `byonoy_l96_reader_unit` |\n| L96A reader unit only | `ByonoyLuminescence96` | `byonoy_l96a_reader_unit` |\n| L96 base unit only | `ByonoyLuminescenceBaseUnit` | `byonoy_l96_base_unit` |\n| L96A base unit only | `ByonoyLuminescenceBaseUnit` | `byonoy_l96a_base_unit` |\n\n- **Communication**: USB HID (VID `0x16D0` / PID `0x119B`)\n- **Communication level**: Firmware", - "metadata": {} + "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": "9y33vci34d6", - "source": "## Setup\n\nUse `byonoy_l96a` (automate) or `byonoy_l96` (manual) to create the full setup (base unit + reader unit). The reader unit is both a `Resource` and a `Device`.", - "metadata": {} + "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", - "id": "g7yhpou4ecd", - "source": "from pylabrobot.byonoy import byonoy_l96a\n\nbase, reader = byonoy_l96a(name=\"l96a\")\nawait reader.setup()", - "metadata": {}, "execution_count": null, - "outputs": [] + "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": "yaf5kv2g5np", - "source": "## Luminescence\n\nThe luminescence capability is exposed as `reader.luminescence`. For the full API, see [Luminescence](../../capabilities/luminescence).\n\nBefore reading, remove the reader unit from the base so a plate can be assigned, then place the reader unit back.", + "id": "05b48622", + "source": "## Resource layout\n\nThe reader unit is both a `Resource` and a `Device`. The base unit owns two child holders, and the interlock lives on the plate holder:\n\n```\nByonoyLuminescenceBaseUnit (base)\n +-- plate_holder (assign plates here)\n +-- reader_unit_holder (reader unit sits here during measurement)\n```\n\nFor the full capability surface (parameters, return types) see [Luminescence](../../capabilities/luminescence).", "metadata": {} }, + { + "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", - "id": "ochf7cbgdxi", - "source": "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\n# Remove reader unit so plate can be loaded\nbase.reader_unit_holder.unassign_child_resource(reader)\n\nplate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nbase.plate_holder.assign_child_resource(plate)", + "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, - "outputs": [] + "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", - "id": "h7wn22dkjls", - "source": "results = await reader.luminescence.read_luminescence(plate, focal_height=13.0)", + "execution_count": null, + "id": "s4-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.byonoy import ByonoyLuminescence96Backend\n", + "\n", + "# Preset\n", + "results = await reader.luminescence.read(\n", + " plate=plate,\n", + " focal_height=0,\n", + " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", + " mode=\"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, - "outputs": [] + "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": "3zuj9ae45as", - "source": "### Custom integration time\n\nUse {class}`~pylabrobot.byonoy.luminescence_96.ByonoyLuminescence96Backend.LuminescenceParams` to set the integration time (in seconds, default 2).", - "metadata": {} + "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", - "id": "49joyxhhy8t", - "source": "from pylabrobot.byonoy import ByonoyLuminescence96Backend\n\nresults = await reader.luminescence.read_luminescence(\n plate,\n focal_height=13.0,\n backend_params=ByonoyLuminescence96Backend.LuminescenceParams(integration_time=5),\n)", + "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, - "outputs": [] + "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=\"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": "tjfltsit53", - "source": "## Resource layout\n\nThe Luminescence 96 has an interlock: you cannot assign a plate to the base while the reader unit is on top. In an automated workcell, use a robotic arm to move the reader unit off the base before loading the plate.\n\n```\nByonoyLuminescenceBaseUnit (base)\n +-- plate_holder (assign plates here)\n +-- reader_unit_holder (reader unit sits here during measurement)\n```", - "metadata": {} + "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": "ck97t28eylh", - "source": "## Teardown", - "metadata": {} + "id": "s10-md", + "metadata": {}, + "source": "## 10. Visual feedback (LED bar)\n\nThe L96 has a 20-pixel addressable RGB front bar. Useful in a workcell to flag run state — solid colors for status, firmware-driven animations (`BREATHING`, `CYLON`, etc.) for \"busy\" indicators, or per-pixel control for progress bars and custom animations.\n\nSee the dedicated [LED bar notebook](led_bar.ipynb) for the full surface and recipes (KITT scanner, gradients, etc.). Quick taste below." }, { "cell_type": "code", - "id": "thvjaquzdj", - "source": "await reader.stop()", + "execution_count": null, + "id": "s10-code", + "metadata": {}, + "outputs": [], + "source": "await reader.driver.set_led_color((0, 255, 0)) # solid green: ready\nawait reader.driver.set_led_color((0, 255, 0), \"breathing\", duration_ms=10000) # busy\nawait reader.driver.set_led_colors([(255, 0, 0)] * 10 + [(0, 0, 255)] * 10) # per-pixel split" + }, + { + "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, - "outputs": [] + "id": "s11-code", + "metadata": {}, + "outputs": [], + "source": "import asyncio, time\nimport numpy as np\nfrom pylabrobot.byonoy import byonoy_l96, ByonoyLuminescence96Backend\nfrom pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\n# Connect\nbase, reader = byonoy_l96(name=\"assay\")\nawait reader.setup()\nawait reader.driver.set_led_color((255, 150, 0)) # amber: prep\n\n# Sanity check\nstatus = await reader.driver.get_status()\ninfo = await reader.driver.get_device_info()\nprint(f\"{info.device_name} sn={info.serial_no} — {status.slot_state.name}\")\nassert status.error_code == 0\n\n# Load plate\nbase.reader_unit_holder.unassign_child_resource(reader)\nplate = Cor_96_wellplate_360ul_Fb(name=\"assay_plate\")\nbase.plate_holder.assign_child_resource(plate)\n# (operator places plate, places detector back on top)\n\n# Read — green while measuring\nawait reader.driver.set_led_color((0, 255, 0))\nresults = await reader.luminescence.read(\n plate=plate,\n focal_height=0,\n backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n mode=\"sensitive\",\n ),\n)\ndata = np.array(results[0].data) # 8 × 12\n\n# Save + tidy up\nnp.save(f\"luminescence_{int(time.time())}.npy\", data)\nawait reader.driver.set_led_color((0, 0, 0)) # off\nawait 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." + ] } ], "metadata": { diff --git a/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb b/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb deleted file mode 100644 index b0c7653e35a..00000000000 --- a/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb +++ /dev/null @@ -1,389 +0,0 @@ -{ - "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\nThe L96 has a 20-pixel addressable RGB front bar. Useful in a workcell to flag run state — solid colors for status, firmware-driven animations (`BREATHING`, `CYLON`, etc.) for \"busy\" indicators, or per-pixel control for progress bars and custom animations.\n\nSee the dedicated [LED bar notebook](led_bar.ipynb) for the full surface and recipes (KITT scanner, gradients, etc.). Quick taste below." - }, - { - "cell_type": "code", - "execution_count": null, - "id": "s10-code", - "metadata": {}, - "outputs": [], - "source": "from pylabrobot.byonoy import LedEffect\n\nawait reader.driver.set_led_color((0, 255, 0)) # solid green: ready\nawait reader.driver.set_led_color((0, 255, 0), LedEffect.BREATHING, duration_ms=10000) # busy\nawait reader.driver.set_led_colors([(255, 0, 0)] * 10 + [(0, 0, 255)] * 10) # per-pixel split" - }, - { - "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\nimport numpy as np\nfrom pylabrobot.byonoy import (\n byonoy_l96, ByonoyLuminescence96Backend,\n Lum96IntegrationMode, LedEffect,\n)\nfrom pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\n# Connect\nbase, reader = byonoy_l96(name=\"assay\")\nawait reader.setup()\nawait reader.driver.set_led_color((255, 150, 0)) # amber: prep\n\n# Sanity check\nstatus = await reader.driver.get_status()\ninfo = await reader.driver.get_device_info()\nprint(f\"{info.device_name} sn={info.serial_no} — {status.slot_state.name}\")\nassert status.error_code == 0\n\n# Load plate\nbase.reader_unit_holder.unassign_child_resource(reader)\nplate = Cor_96_wellplate_360ul_Fb(name=\"assay_plate\")\nbase.plate_holder.assign_child_resource(plate)\n# (operator places plate, places detector back on top)\n\n# Read — green while measuring\nawait reader.driver.set_led_color((0, 255, 0))\nresults = await reader.luminescence.read(\n plate=plate,\n focal_height=0,\n backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n mode=Lum96IntegrationMode.SENSITIVE,\n ),\n)\ndata = np.array(results[0].data) # 8 × 12\n\n# Save + tidy up\nnp.save(f\"luminescence_{int(time.time())}.npy\", data)\nawait reader.driver.set_led_color((0, 0, 0)) # off\nawait 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." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.11.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file From ab5ab667a334d3fd06ddbf212473838af5950404 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 20:20:15 -0700 Subject: [PATCH 21/28] LedEffect/Lum96IntegrationMode: convert from enum to Literal alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing enum parameters in this project use typing.Literal string aliases, not enum classes (see pylabrobot/capabilities/pumping/calibration.py calibration_mode, hamilton/heater_shaker/backend.py direction, etc.). Bring the two byonoy user-input enums in line: LedEffect = Literal["solid","progress","cylon","rainbow", "blinking","breathing"] Lum96IntegrationMode = Literal["rapid","sensitive","ultra_sensitive", "custom"] set_led_color(effect: LedEffect = "solid") and LuminescenceParams.mode: Lum96IntegrationMode = "sensitive" now take strings; internal int/seconds lookups (_LED_EFFECT_CODES, LUM96_PRESET_S) are typed Dict[, ...] so the keys are checked. Output enums (ByonoySlotState, Abs96StatusError, Abs1StatusError) stay as enum classes — the user reads those, doesn't supply them. Notebook (led_bar.ipynb) updated to pass bare string literals; orphan LedEffect import dropped. Validated on BYOMML00066: all LED calls work; "bogus" raises KeyError. Co-Authored-By: Claude Opus 4.7 --- .../byonoy/luminescence_96/led_bar.ipynb | 6 +-- pylabrobot/byonoy/backend.py | 41 +++++++++---------- pylabrobot/byonoy/luminescence_96.py | 19 +++++---- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb b/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb index a2c5fad7ec0..f5977214c54 100644 --- a/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb +++ b/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb @@ -47,7 +47,7 @@ } ], "source": [ - "from pylabrobot.byonoy import byonoy_l96, LedEffect\n", + "from pylabrobot.byonoy import byonoy_l96\n", "\n", "base, reader = byonoy_l96(name=\"l96\")\n", "await reader.setup()\n", @@ -122,10 +122,10 @@ }, "outputs": [], "source": [ - "await drv.set_led_color((0, 255, 0), LedEffect.BREATHING, duration_ms=6000)\n", + "await drv.set_led_color((0, 255, 0), \"breathing\", duration_ms=6000)\n", "await asyncio.sleep(6)\n", "\n", - "await drv.set_led_color((255, 0, 255), LedEffect.CYLON, duration_ms=4000, force=True)\n", + "await drv.set_led_color((255, 0, 255), \"cylon\", duration_ms=4000, force=True)\n", "await asyncio.sleep(4)\n", "\n", "await drv.set_led_color((0, 0, 0)) # back to off" diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index cc1fc0d2020..2c4a1f62805 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 Dict, List, Optional, Tuple +from typing import Dict, List, Literal, Optional, Tuple from pylabrobot.capabilities.capability import BackendParams from pylabrobot.device import Driver @@ -27,18 +27,14 @@ class ByonoySlotState(enum.IntEnum): UNDETERMINED = 3 -class Lum96IntegrationMode(enum.Enum): - RAPID = "rapid" - SENSITIVE = "sensitive" - ULTRA_SENSITIVE = "ultra_sensitive" - CUSTOM = "custom" +Lum96IntegrationMode = Literal["rapid", "sensitive", "ultra_sensitive", "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, +LUM96_PRESET_S: Dict[Lum96IntegrationMode, float] = { + "rapid": 0.1, + "sensitive": 2.0, + "ultra_sensitive": 20.0, } @@ -121,13 +117,16 @@ class ByonoyDeviceInfo: _FLAG_HAS_MORE_DATA = 0x10 -class LedEffect(enum.IntEnum): - SOLID = 0x00 - PROGRESS = 0x01 - CYLON = 0x02 - RAINBOW = 0x03 - BLINKING = 0x04 - BREATHING = 0x05 +LedEffect = Literal["solid", "progress", "cylon", "rainbow", "blinking", "breathing"] + +_LED_EFFECT_CODES: Dict[LedEffect, int] = { + "solid": 0x00, + "progress": 0x01, + "cylon": 0x02, + "rainbow": 0x03, + "blinking": 0x04, + "breathing": 0x05, +} # --- Firmware error codes (per Byonoy hid-reports source) ------------------- @@ -425,7 +424,7 @@ async def cancel(self, report_id: int = 0x0340) -> None: async def set_led_color( self, color: Tuple[int, int, int], - effect: LedEffect = LedEffect.SOLID, + effect: LedEffect = "solid", *, low_power: bool = False, force: bool = False, @@ -436,8 +435,8 @@ async def set_led_color( Mirrors the vendor's user-facing `set_led_effect(effect, color, modes, ...)` in byonoy_device_library. The firmware renders `effect` over `color`: - SOLID just shows the color; BREATHING/CYLON/BLINKING/RAINBOW/PROGRESS - animate it. + "solid" just shows the color; "breathing"/"cylon"/"blinking"/"rainbow"/ + "progress" animate it. Packed layout (vendor byonoyusbhid.h led_bar_effects_out_t): effect:u8 color:(r,g,b u8) effect_state:u8 flags:u8 duration_ms:u32 @@ -452,7 +451,7 @@ async def set_led_color( r_, g, b = color payload = ( Writer() - .u8(int(effect)) + .u8(_LED_EFFECT_CODES[effect]) .u8(r_ & 0xFF).u8(g & 0xFF).u8(b & 0xFF) .u8(effect_state & 0xFF) .u8(flags) diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index 338eb86c6cd..37408fa37c7 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -45,10 +45,11 @@ class LuminescenceParams(BackendParams): """Byonoy Luminescence 96 parameters for luminescence reads. Args: - 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`. + 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 reported (defaulting to all 96). Note: this is an output filter, @@ -57,7 +58,7 @@ class LuminescenceParams(BackendParams): for cleaner downstream processing; does not reduce read time. """ - mode: Lum96IntegrationMode = Lum96IntegrationMode.SENSITIVE + mode: Lum96IntegrationMode = "sensitive" integration_time: Optional[float] = None selected_wells: Optional[List[bool]] = None @@ -86,10 +87,10 @@ async def read_luminescence( # Resolve mode + integration time if backend_params.integration_time is not None: - mode = Lum96IntegrationMode.CUSTOM + mode = "custom" integration_time = backend_params.integration_time - elif backend_params.mode == Lum96IntegrationMode.CUSTOM: - raise ValueError("CUSTOM mode requires integration_time to be set.") + elif backend_params.mode == "custom": + raise ValueError("'custom' mode requires integration_time to be set.") else: mode = backend_params.mode integration_time = LUM96_PRESET_S[mode] @@ -108,7 +109,7 @@ async def read_luminescence( "integration_time=%.3fs, wells=%d/96", self.io.pid, plate.name, - mode.name, + mode, integration_time, sum(mask_bools), ) From 194f769fd942f814514abb300691e6f0658f5e6f Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 20:27:32 -0700 Subject: [PATCH 22/28] cancel(): drop report_id arg; auto-detect in-flight trigger; raise on concurrent read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user shouldn't have to know that lum96's trigger is 0x0340 and abs96's is 0x0320 just to abort a measurement. Track the in-flight trigger on the driver and let cancel() pick it up automatically. ByonoyDriver._measurement_in_flight(report_id): contextmanager that sets _in_flight_trigger for the duration of the measurement transaction, clears _abort_requested on entry, and clears _in_flight_trigger in finally. Raises RuntimeError on entry if another transaction is already in flight — no lock, no silent queuing, just a loud "device busy" so the caller's intent is obvious. cancel(): no args. Reads _in_flight_trigger; no-op if nothing is in flight, otherwise sets _abort_requested + sends REP_ABORT_REPORT_OUT (0x0060) targeting the tracked report. The held read loop polls the flag and unwinds with asyncio.CancelledError. read_luminescence and _run_abs_measurement now wrap their trigger+result loop in _measurement_in_flight. Abs96 also gains the _abort_requested poll in its read loop — it was missing before, so cancel() mid-absorbance would have done nothing. hello-world.ipynb: cancel(report_id=0x0340) -> cancel(); troubleshooting row about "wrong report id" replaced with the new auto-detect note. Validated on BYOMML00066 (lum96): no-op cancel, auto-detect cancel mid-read, and busy-raise on concurrent read all pass. Abs96 change is line-for-line mirror of lum96 but unverified on hardware (no abs96 connected). Co-Authored-By: Claude Opus 4.7 --- .../byonoy/luminescence_96/hello-world.ipynb | 6 +- pylabrobot/byonoy/absorbance_96.py | 109 +++++++++-------- pylabrobot/byonoy/backend.py | 44 +++++-- pylabrobot/byonoy/luminescence_96.py | 113 +++++++++--------- 4 files changed, 150 insertions(+), 122 deletions(-) diff --git a/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb index 33b26ac3124..fb5b06deb3b 100644 --- a/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb +++ b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb @@ -275,7 +275,7 @@ " )\n", ")\n", "await asyncio.sleep(1.0)\n", - "await reader.driver.cancel(report_id=0x0340)\n", + "await reader.driver.cancel()\n", "try:\n", " await task\n", "except asyncio.CancelledError:\n", @@ -363,7 +363,7 @@ "| 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", + "| `cancel()` returns immediately, read keeps going | No measurement in flight | `cancel()` auto-detects the in-flight trigger; no-op if there isn't one |\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 |" ] @@ -392,4 +392,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index d417e4a7e77..4bcc1f8855b 100644 --- a/pylabrobot/byonoy/absorbance_96.py +++ b/pylabrobot/byonoy/absorbance_96.py @@ -1,3 +1,4 @@ +import asyncio import logging import time from typing import List, Optional, Tuple @@ -58,61 +59,65 @@ async def request_available_absorbance_wavelengths(self) -> List[float]: return [w for w in available_wavelengths if w != 0] async def _run_abs_measurement(self, signal_wl: int, reference_wl: int, is_reference: bool): - await self.send_command( - report_id=0x0010, - payload=b"\x00" * 60, - wait_for_response=False, - ) + with self._measurement_in_flight(0x0320): + await self.send_command( + report_id=0x0010, + payload=b"\x00" * 60, + wait_for_response=False, + ) - payload2 = Writer().u16(7).u8(0).raw_bytes(b"\x00" * 52).finish() - await self.send_command( - report_id=0x0200, - payload=payload2, - wait_for_response=False, - ) + payload2 = Writer().u16(7).u8(0).raw_bytes(b"\x00" * 52).finish() + await self.send_command( + report_id=0x0200, + payload=payload2, + wait_for_response=False, + ) - payload3 = Writer().i16(signal_wl).i16(reference_wl).u8(int(is_reference)).u8(0).finish() - await self.send_command( - report_id=0x0320, - payload=payload3, - wait_for_response=False, - routing_info=b"\x00\x40", - ) + payload3 = Writer().i16(signal_wl).i16(reference_wl).u8(int(is_reference)).u8(0).finish() + await self.send_command( + report_id=0x0320, + payload=payload3, + wait_for_response=False, + routing_info=b"\x00\x40", + ) - rows: List[float] = [] - t0 = time.time() - - while True: - if time.time() - t0 > 120: - logger.error( - "[Byonoy A96 pid=0x%04X] measurement timed out after 120s (signal=%d nm, ref=%d nm)", - self.io.pid, - signal_wl, - reference_wl, - ) - raise TimeoutError("Measurement timeout.") - - chunk = await self.io.read(64, timeout=30) - if len(chunk) == 0: - continue - - reader = Reader(chunk) - report_id = reader.u16() - - if report_id == 0x0500: - seq = reader.u8() - seq_len = reader.u8() - _ = reader.i16() # signal_wl_nm - _ = reader.i16() # reference_wl_nm - _ = reader.u32() # duration_ms - row = [reader.f32() for _ in range(12)] - _ = reader.u8() # flags - _ = reader.u8() # progress - - rows.extend(row) - - if seq == seq_len - 1: - break + rows: List[float] = [] + t0 = time.time() + + while True: + if self._abort_requested: + logger.info("[Byonoy A96 pid=0x%04X] measurement aborted by cancel()", self.io.pid) + raise asyncio.CancelledError("Absorbance measurement aborted via cancel().") + if time.time() - t0 > 120: + logger.error( + "[Byonoy A96 pid=0x%04X] measurement timed out after 120s (signal=%d nm, ref=%d nm)", + self.io.pid, + signal_wl, + reference_wl, + ) + raise TimeoutError("Measurement timeout.") + + chunk = await self.io.read(64, timeout=30) + if len(chunk) == 0: + continue + + reader = Reader(chunk) + report_id = reader.u16() + + if report_id == 0x0500: + seq = reader.u8() + seq_len = reader.u8() + _ = reader.i16() # signal_wl_nm + _ = reader.i16() # reference_wl_nm + _ = reader.u32() # duration_ms + row = [reader.f32() for _ in range(12)] + _ = reader.u8() # flags + _ = reader.u8() # progress + + rows.extend(row) + + if seq == seq_len - 1: + break return rows diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 2c4a1f62805..e6f959ef8fa 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -1,11 +1,12 @@ import asyncio +import contextlib import enum import logging import threading import time from abc import ABCMeta from dataclasses import dataclass -from typing import Dict, List, Literal, Optional, Tuple +from typing import Dict, Iterator, List, Literal, Optional, Tuple from pylabrobot.capabilities.capability import BackendParams from pylabrobot.device import Driver @@ -191,6 +192,7 @@ def __init__(self, pid: int, device_type: ByonoyDevice) -> None: self._sending_pings = False self._device_type = device_type self._abort_requested = False + self._in_flight_trigger: Optional[int] = None async def setup(self, backend_params: Optional[BackendParams] = None) -> None: await self.io.setup() @@ -405,21 +407,43 @@ async def s(idx: int) -> str: 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). + @contextlib.contextmanager + def _measurement_in_flight(self, report_id: int) -> Iterator[None]: + """Mark `report_id` as the in-flight measurement trigger for the duration + of the `with` block. Subclasses' read methods wrap their trigger + result + loop in this so `cancel()` can find the right report to abort and so a + concurrent second read raises instead of corrupting the read buffer. + """ + if self._in_flight_trigger is not None: + raise RuntimeError( + f"Byonoy device busy: report 0x{self._in_flight_trigger:04X} already in " + f"flight; call cancel() before starting 0x{report_id:04X}." + ) + self._in_flight_trigger = report_id + self._abort_requested = False + try: + yield + finally: + self._in_flight_trigger = None + + async def cancel(self) -> None: + """Abort the in-flight 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. + Uses the report id tracked by the read method's `_measurement_in_flight` + context. If no measurement is in flight, this is a no-op. - `report_id` is the trigger report whose execution should be aborted. - Defaults to the lum96 trigger (0x0340). + Empirically the firmware stops emitting result chunks but sends no closing + notification, so we also raise `_abort_requested`; subclasses' read loops + poll the flag and bail out instead of waiting 120 s for the hard timeout. """ + report_id = self._in_flight_trigger + if report_id is None: + logger.info("[Byonoy] cancel(): no measurement in flight; no-op") + return 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) + logger.info("[Byonoy] sent abort for in-flight report 0x%04X", report_id) async def set_led_color( self, diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index 37408fa37c7..0abae71942d 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -114,66 +114,65 @@ async def read_luminescence( sum(mask_bools), ) - await self.send_command( - report_id=0x0010, - payload=b"\x00" * 60, - wait_for_response=False, - ) + with self._measurement_in_flight(0x0340): + await self.send_command( + report_id=0x0010, + payload=b"\x00" * 60, + wait_for_response=False, + ) - payload2 = Writer().u16(7).u8(0).raw_bytes(b"\x00" * 52).finish() - await self.send_command( - report_id=0x0200, - payload=payload2, - wait_for_response=False, - ) + payload2 = Writer().u16(7).u8(0).raw_bytes(b"\x00" * 52).finish() + await self.send_command( + report_id=0x0200, + payload=payload2, + wait_for_response=False, + ) - payload3 = ( - Writer() - .i32(int(integration_time * 1_000_000)) - .raw_bytes(well_mask) - .u8(0) # is_reference_measurement - .u8(0) # flags - .finish() - ) - self._abort_requested = False - await self.send_command( - report_id=0x0340, - payload=payload3, - wait_for_response=False, - ) + payload3 = ( + 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, + payload=payload3, + wait_for_response=False, + ) - t0 = time.time() - 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=2) - if len(chunk) == 0: - continue - - reader = Reader(chunk) - report_id = reader.u16() - - if report_id == 0x0600: - seq = reader.u8() - seq_len = reader.u8() - _ = reader.u32() # integration_time_us - _ = reader.u32() # duration_ms - row = [reader.f32() for _ in range(12)] - _ = reader.u8() # flags - _ = reader.u8() # progress - - all_rows.extend(row) - - if seq == seq_len - 1: - break + t0 = time.time() + all_rows: List[Optional[float]] = [] + + while True: + if self._abort_requested: + 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=2) + if len(chunk) == 0: + continue + + reader = Reader(chunk) + report_id = reader.u16() + + if report_id == 0x0600: + seq = reader.u8() + seq_len = reader.u8() + _ = reader.u32() # integration_time_us + _ = reader.u32() # duration_ms + row = [reader.f32() for _ in range(12)] + _ = reader.u8() # flags + _ = reader.u8() # progress + + all_rows.extend(row) + + if seq == seq_len - 1: + break hybrid_result: List[Optional[float]] = all_rows[96 * 0 : 96 * 1] From d3be18a92ee819958706e009a1d4d6de2b9f6146 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 21:01:42 -0700 Subject: [PATCH 23/28] byonoy: address PR #1027 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rename device-query methods get_* → request_* (matches d36417249 convention) - read loops: call request_status() + raise on firmware error; surface non-zero per-chunk flags as a warning - LuminescenceResult: None for unmeasured wells (was 0.0, which is a legitimate measurement); raise on firmware fault instead of silently returning zeroed data - LuminescenceParams: drop selected_wells; single source of truth is the wells arg - _measurement_in_flight: also reset _abort_requested in finally - drop dead background ping worker (+ asyncio/threading imports) - drop dead-looking all_rows[96*0:96*1] slice; assert len==96 - effect_state docstring (0..255, PROGRESS effect only) - ByonoyDriver.name property sourced from io._human_readable_device_name; subclasses pass "Byonoy L96"/"Byonoy A96"; logs use self.name not pid hex - add backend_tests.py: 22 tests for encode_well_bitmask, error tables, describe_error_code, LED codes, presets Co-Authored-By: Claude Opus 4.7 (1M context) --- .../byonoy/luminescence_96/hello-world.ipynb | 39 ++--- pylabrobot/byonoy/absorbance_96.py | 37 +++-- pylabrobot/byonoy/backend.py | 84 ++++------- pylabrobot/byonoy/backend_tests.py | 141 ++++++++++++++++++ pylabrobot/byonoy/luminescence_96.py | 60 +++++--- 5 files changed, 243 insertions(+), 118 deletions(-) create mode 100644 pylabrobot/byonoy/backend_tests.py diff --git a/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb index fb5b06deb3b..c5a7b68a172 100644 --- a/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb +++ b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb @@ -154,13 +154,7 @@ "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." - ] + "source": "## 5. Reading specific wells\n\nPass a `wells=` list to `read()` — only those wells get real values back; everything else comes back as `None`. The result shape is still 8×12 (per the `LuminescenceResult` contract); unmeasured cells are `None` so you can distinguish \"didn't read\" from a legitimate `0.0` reading (baseline subtraction can yield ~0 or negative values).\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). The `wells` filter keeps your downstream tidy — it doesn't save time. If you want fast, use `RAPID` mode." }, { "cell_type": "code", @@ -168,20 +162,7 @@ "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", - ")" - ] + "source": "# Only column 1 (A1, B1, ..., H1)\ncol1_wells = [plate.get_well(f\"{r}1\") for r in \"ABCDEFGH\"]\n\nresults = await reader.luminescence.read(\n plate=plate,\n focal_height=0,\n wells=col1_wells,\n)\n# results[0].data[0][0] is A1 (float); results[0].data[0][1] is A2 (None)" }, { "cell_type": "markdown", @@ -303,12 +284,12 @@ "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", + "status = await reader.driver.request_status()\n", + "env = await reader.driver.request_environment()\n", + "info = await reader.driver.request_device_info()\n", + "versions = await reader.driver.request_versions()\n", + "api = await reader.driver.request_api_version()\n", + "supported = await reader.driver.request_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", @@ -347,7 +328,7 @@ "id": "s11-code", "metadata": {}, "outputs": [], - "source": "import asyncio, time\nimport numpy as np\nfrom pylabrobot.byonoy import byonoy_l96, ByonoyLuminescence96Backend\nfrom pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\n# Connect\nbase, reader = byonoy_l96(name=\"assay\")\nawait reader.setup()\nawait reader.driver.set_led_color((255, 150, 0)) # amber: prep\n\n# Sanity check\nstatus = await reader.driver.get_status()\ninfo = await reader.driver.get_device_info()\nprint(f\"{info.device_name} sn={info.serial_no} — {status.slot_state.name}\")\nassert status.error_code == 0\n\n# Load plate\nbase.reader_unit_holder.unassign_child_resource(reader)\nplate = Cor_96_wellplate_360ul_Fb(name=\"assay_plate\")\nbase.plate_holder.assign_child_resource(plate)\n# (operator places plate, places detector back on top)\n\n# Read — green while measuring\nawait reader.driver.set_led_color((0, 255, 0))\nresults = await reader.luminescence.read(\n plate=plate,\n focal_height=0,\n backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n mode=\"sensitive\",\n ),\n)\ndata = np.array(results[0].data) # 8 × 12\n\n# Save + tidy up\nnp.save(f\"luminescence_{int(time.time())}.npy\", data)\nawait reader.driver.set_led_color((0, 0, 0)) # off\nawait reader.stop()" + "source": "import asyncio, time\nimport numpy as np\nfrom pylabrobot.byonoy import byonoy_l96, ByonoyLuminescence96Backend\nfrom pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\n# Connect\nbase, reader = byonoy_l96(name=\"assay\")\nawait reader.setup()\nawait reader.driver.set_led_color((255, 150, 0)) # amber: prep\n\n# Sanity check\nstatus = await reader.driver.request_status()\ninfo = await reader.driver.request_device_info()\nprint(f\"{info.device_name} sn={info.serial_no} — {status.slot_state.name}\")\nassert status.error_code == 0\n\n# Load plate\nbase.reader_unit_holder.unassign_child_resource(reader)\nplate = Cor_96_wellplate_360ul_Fb(name=\"assay_plate\")\nbase.plate_holder.assign_child_resource(plate)\n# (operator places plate, places detector back on top)\n\n# Read — green while measuring\nawait reader.driver.set_led_color((0, 255, 0))\nresults = await reader.luminescence.read(\n plate=plate,\n focal_height=0,\n backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n mode=\"sensitive\",\n ),\n)\ndata = np.array(results[0].data) # 8 × 12\n\n# Save + tidy up\nnp.save(f\"luminescence_{int(time.time())}.npy\", data)\nawait reader.driver.set_led_color((0, 0, 0)) # off\nawait reader.stop()" }, { "cell_type": "markdown", @@ -392,4 +373,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index 4bcc1f8855b..aa43eaecf24 100644 --- a/pylabrobot/byonoy/absorbance_96.py +++ b/pylabrobot/byonoy/absorbance_96.py @@ -33,7 +33,7 @@ class ByonoyAbsorbance96Backend(ByonoyDriver, AbsorbanceBackend): _ERROR_NAMES = ABS96_ERROR_NAMES def __init__(self) -> None: - super().__init__(pid=0x1199, device_type=ByonoyDevice.ABSORBANCE_96) + super().__init__(pid=0x1199, device_type=ByonoyDevice.ABSORBANCE_96, name="Byonoy A96") self.available_wavelengths: List[float] = [] async def setup(self, backend_params: Optional["BackendParams"] = None) -> None: @@ -41,8 +41,8 @@ async def setup(self, backend_params: Optional["BackendParams"] = None) -> None: await self.initialize_measurements() self.available_wavelengths = await self.request_available_absorbance_wavelengths() logger.info( - "[Byonoy A96 pid=0x%04X] ready, available wavelengths: %s nm", - self.io.pid, + "[%s] ready, available wavelengths: %s nm", + self.name, self.available_wavelengths, ) @@ -82,16 +82,17 @@ async def _run_abs_measurement(self, signal_wl: int, reference_wl: int, is_refer ) rows: List[float] = [] + chunk_flags: List[int] = [] # vendor bit definitions unpublished; surface non-zero t0 = time.time() while True: if self._abort_requested: - logger.info("[Byonoy A96 pid=0x%04X] measurement aborted by cancel()", self.io.pid) + logger.info("[%s] measurement aborted by cancel()", self.name) raise asyncio.CancelledError("Absorbance measurement aborted via cancel().") if time.time() - t0 > 120: logger.error( - "[Byonoy A96 pid=0x%04X] measurement timed out after 120s (signal=%d nm, ref=%d nm)", - self.io.pid, + "[%s] measurement timed out after 120s (signal=%d nm, ref=%d nm)", + self.name, signal_wl, reference_wl, ) @@ -111,14 +112,30 @@ async def _run_abs_measurement(self, signal_wl: int, reference_wl: int, is_refer _ = reader.i16() # reference_wl_nm _ = reader.u32() # duration_ms row = [reader.f32() for _ in range(12)] - _ = reader.u8() # flags - _ = reader.u8() # progress + flags = reader.u8() + _ = reader.u8() # progress (0..100 running %); not surfaced rows.extend(row) + chunk_flags.append(flags) if seq == seq_len - 1: break + status = await self.request_status() + if status.error_code != 0: + raise RuntimeError( + f"{self.name} firmware error after measurement (signal={signal_wl} nm, " + f"ref={reference_wl} nm): {self.describe_error_code(status.error_code)} " + f"(chunk flags: {[f'0x{f:02x}' for f in chunk_flags]})" + ) + if any(f != 0 for f in chunk_flags): + logger.warning( + "[%s] non-zero chunk flags during measurement: %s " + "(vendor bit definitions not published; data may be unreliable)", + self.name, + [f"0x{f:02x}" for f in chunk_flags], + ) + return rows async def initialize_measurements(self): @@ -142,8 +159,8 @@ async def read_absorbance( ) logger.info( - "[Byonoy A96 pid=0x%04X] reading absorbance: plate='%s', wavelength=%d nm, wells=%d/%d", - self.io.pid, + "[%s] reading absorbance: plate='%s', wavelength=%d nm, wells=%d/%d", + self.name, plate.name, wavelength, len(wells), diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index e6f959ef8fa..01f45c00171 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -1,8 +1,6 @@ -import asyncio import contextlib import enum import logging -import threading import time from abc import ABCMeta from dataclasses import dataclass @@ -183,30 +181,24 @@ class ByonoyDriver(Driver, metaclass=ABCMeta): # 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: + def __init__(self, pid: int, device_type: ByonoyDevice, name: str) -> None: super().__init__() - self.io = HID(human_readable_device_name="Byonoy Plate Reader", vid=0x16D0, pid=pid) - self._background_thread: Optional[threading.Thread] = None - self._stop_background = threading.Event() - self._ping_interval = 1.0 - self._sending_pings = False + self.io = HID(human_readable_device_name=name, vid=0x16D0, pid=pid) self._device_type = device_type self._abort_requested = False self._in_flight_trigger: Optional[int] = None + @property + def name(self) -> str: + return self.io._human_readable_device_name + async def setup(self, backend_params: Optional[BackendParams] = None) -> None: await self.io.setup() - logger.info("[Byonoy %s pid=0x%04X] connected", self._device_type.name, self.io.pid) - self._stop_background.clear() - self._background_thread = threading.Thread(target=self._background_ping_worker, daemon=True) - self._background_thread.start() + logger.info("[%s] connected", self.name) async def stop(self) -> None: - self._stop_background.set() - if self._background_thread and self._background_thread.is_alive(): - self._background_thread.join(timeout=2.0) await self.io.stop() - logger.info("[Byonoy %s pid=0x%04X] disconnected", self._device_type.name, self.io.pid) + logger.info("[%s] disconnected", self.name) def _assemble_command(self, report_id: int, payload: bytes, routing_info: bytes) -> bytes: packet = Writer().u16(report_id).raw_bytes(payload).finish() @@ -229,9 +221,8 @@ async def send_command( while True: if time.time() - t0 > 120: logger.error( - "[Byonoy %s pid=0x%04X] timeout waiting for response to command 0x%04X after 120s", - self._device_type.name, - self.io.pid, + "[%s] timeout waiting for response to command 0x%04X after 120s", + self.name, report_id, ) raise TimeoutError("Reading data timed out after 2 minutes.") @@ -243,35 +234,7 @@ async def send_command( break return response - def _background_ping_worker(self) -> None: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(self._ping_loop()) - except Exception: - logger.error("Background ping worker crashed", exc_info=True) - finally: - loop.close() - - async def _ping_loop(self) -> None: - while not self._stop_background.is_set(): - if self._sending_pings: - payload = Writer().u8(1).finish() - cmd = self._assemble_command( - report_id=0x0040, - payload=payload, - routing_info=b"\x00\x00", - ) - await self.io.write(cmd) - self._stop_background.wait(self._ping_interval) - - def _start_background_pings(self) -> None: - self._sending_pings = True - - def _stop_background_pings(self) -> None: - self._sending_pings = False - - async def get_status(self) -> ByonoyStatus: + async def request_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, routing_info=b"\x80\x40" @@ -301,7 +264,7 @@ def describe_error_code(self, code: int) -> str: return self._ERROR_NAMES[code] return f"errorCode=0x{code:02x}" - async def get_environment(self) -> ByonoyEnvironment: + async def request_environment(self) -> ByonoyEnvironment: """Read REP_ENVIRONMENT_IN (0x0310): temperature, humidity, acceleration.""" response = await self.send_command( report_id=0x0310, payload=b"\x00" * 60, routing_info=b"\x80\x40" @@ -317,7 +280,7 @@ 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: + async def request_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" @@ -326,7 +289,7 @@ async def get_api_version(self) -> ByonoyApiVersion: r = Reader(response[2:]) return ByonoyApiVersion(version_no=r.u32()) - async def get_supported_reports(self) -> List[int]: + async def request_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 @@ -377,7 +340,8 @@ async def read_data_field(self, field_index: int) -> object: 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", + "[%s] field 0x%04X has more data than fits in one report; truncating", + self.name, field_index, ) raw = r.raw_bytes(52) @@ -391,7 +355,7 @@ async def read_data_field(self, field_index: int) -> object: return raw[0] != 0 return raw # TypeBytes - async def get_device_info(self) -> ByonoyDeviceInfo: + async def request_device_info(self) -> ByonoyDeviceInfo: """Read identity strings (matches C lib's byonoy_get_device_information).""" async def s(idx: int) -> str: @@ -419,12 +383,15 @@ def _measurement_in_flight(self, report_id: int) -> Iterator[None]: f"Byonoy device busy: report 0x{self._in_flight_trigger:04X} already in " f"flight; call cancel() before starting 0x{report_id:04X}." ) + # Entry-side reset is load-bearing for correctness; exit-side is hygiene + # so a between-reads inspection doesn't see stale True from a prior cancel. self._in_flight_trigger = report_id self._abort_requested = False try: yield finally: self._in_flight_trigger = None + self._abort_requested = False async def cancel(self) -> None: """Abort the in-flight measurement via REP_ABORT_REPORT_OUT (0x0060). @@ -438,12 +405,12 @@ async def cancel(self) -> None: """ report_id = self._in_flight_trigger if report_id is None: - logger.info("[Byonoy] cancel(): no measurement in flight; no-op") + logger.info("[%s] cancel(): no measurement in flight; no-op", self.name) return 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 in-flight report 0x%04X", report_id) + logger.info("[%s] sent abort for in-flight report 0x%04X", self.name, report_id) async def set_led_color( self, @@ -468,6 +435,11 @@ async def set_led_color( `force` (FLAG_LED_FORCE=0x10) overrides an unexpired previous `duration_ms`. `low_power` (FLAG_LED_LOWPOWER=0x01) reduces brightness. + `effect_state` is a 0..255 parameter used only by the "progress" effect + to indicate fill level (0 = empty bar, 255 = full bar). It is ignored + by every other effect ("solid", "breathing", "blinking", "cylon", + "rainbow"); leave it at 0 unless you're driving progress. + The PC routing tag (request_info=0x4000) is required — the firmware silently drops LED writes that arrive with the default LEGACY tag. """ @@ -504,7 +476,7 @@ async def set_led_colors(self, colors: List[Tuple[int, int, int]]) -> None: routing_info=b"\x00\x40", ) - async def get_versions(self) -> ByonoyVersions: + async def request_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, routing_info=b"\x80\x40" diff --git a/pylabrobot/byonoy/backend_tests.py b/pylabrobot/byonoy/backend_tests.py new file mode 100644 index 00000000000..7fd981f6022 --- /dev/null +++ b/pylabrobot/byonoy/backend_tests.py @@ -0,0 +1,141 @@ +import unittest + +from pylabrobot.byonoy.backend import ( + ABS1_ERROR_NAMES, + ABS96_ERROR_NAMES, + Abs1StatusError, + Abs96StatusError, + ByonoyDevice, + ByonoyDriver, + LUM96_PRESET_S, + _GENERIC_ERROR_NAMES, + _LED_EFFECT_CODES, + encode_well_bitmask, +) + + +class EncodeWellBitmaskTests(unittest.TestCase): + def test_all_false_is_zero_filled(self): + self.assertEqual(encode_well_bitmask([False] * 96), b"\x00" * 12) + + def test_all_true_is_all_ones(self): + self.assertEqual(encode_well_bitmask([True] * 96), b"\xff" * 12) + + def test_a1_only_sets_byte0_bit0(self): + bools = [False] * 96 + bools[0] = True + self.assertEqual(encode_well_bitmask(bools), b"\x01" + b"\x00" * 11) + + def test_h12_only_sets_byte11_bit7(self): + bools = [False] * 96 + bools[95] = True + self.assertEqual(encode_well_bitmask(bools), b"\x00" * 11 + b"\x80") + + def test_bits_7_and_8_cross_byte_boundary(self): + bools = [False] * 96 + bools[7] = True # byte 0, bit 7 → 0x80 + bools[8] = True # byte 1, bit 0 → 0x01 + self.assertEqual(encode_well_bitmask(bools), b"\x80\x01" + b"\x00" * 10) + + def test_first_column_has_8_bits_set(self): + bools = [False] * 96 + for r in range(8): + bools[r * 12] = True + result = encode_well_bitmask(bools) + self.assertEqual(sum(bin(b).count("1") for b in result), 8) + + def test_custom_n_size(self): + # bit 0 + bit 2 → 0x05 + self.assertEqual(encode_well_bitmask([True, False, True], n=3), b"\x05") + + def test_length_mismatch_raises(self): + with self.assertRaises(ValueError): + encode_well_bitmask([True] * 95) + with self.assertRaises(ValueError): + encode_well_bitmask([True] * 96, n=24) + + +class IntegrationModePresetTests(unittest.TestCase): + def test_preset_values_match_vendor(self): + self.assertEqual(LUM96_PRESET_S["rapid"], 0.1) + self.assertEqual(LUM96_PRESET_S["sensitive"], 2.0) + self.assertEqual(LUM96_PRESET_S["ultra_sensitive"], 20.0) + + def test_custom_is_not_a_preset(self): + # "custom" is a Literal value but has no preset — read_luminescence + # requires the caller to set integration_time explicitly. + self.assertNotIn("custom", LUM96_PRESET_S) + + +class ErrorTableTests(unittest.TestCase): + def test_generic_table_has_only_no_error(self): + self.assertEqual(_GENERIC_ERROR_NAMES, {0: "NO_ERROR"}) + + def test_abs96_table_round_trips(self): + self.assertEqual(ABS96_ERROR_NAMES[0], "NO_ERROR") + self.assertEqual(ABS96_ERROR_NAMES[1], "ERROR_CALIB") + self.assertEqual(ABS96_ERROR_NAMES[Abs96StatusError.ERROR_NO_ACK], "ERROR_NO_ACK") + + def test_abs1_flag_bit_values(self): + # AbsOne is a bit-flag enum; verify bit positions match the vendor header. + self.assertEqual(Abs1StatusError.AMBIENT_LIGHT.value, 1) + self.assertEqual(Abs1StatusError.MIN_LIGHT.value, 2) + self.assertEqual(Abs1StatusError.USB.value, 4) + self.assertEqual(Abs1StatusError.HARDWARE.value, 8) + self.assertEqual(Abs1StatusError.NOISE_LIMIT.value, 128) + + def test_abs1_supports_combined_flags(self): + combined = Abs1StatusError.AMBIENT_LIGHT | Abs1StatusError.HARDWARE + self.assertEqual(combined.value, 9) + + def test_abs1_table_includes_all_enum_members(self): + for member in Abs1StatusError: + self.assertIn(member.value, ABS1_ERROR_NAMES) + + +class DescribeErrorCodeTests(unittest.TestCase): + """describe_error_code() is pure — bypass __init__ to avoid HID setup.""" + + def _make_driver(self, error_names): + drv = ByonoyDriver.__new__(ByonoyDriver) + drv._ERROR_NAMES = error_names + return drv + + def test_known_code_returns_name(self): + drv = self._make_driver(ABS96_ERROR_NAMES) + self.assertEqual(drv.describe_error_code(1), "ERROR_CALIB") + self.assertEqual(drv.describe_error_code(0), "NO_ERROR") + + def test_unknown_code_falls_back_to_hex(self): + drv = self._make_driver(ABS96_ERROR_NAMES) + self.assertEqual(drv.describe_error_code(0xAB), "errorCode=0xab") + + def test_generic_table_only_knows_no_error(self): + # Lum96 uses the generic table — anything non-zero is the hex sentinel. + drv = self._make_driver(_GENERIC_ERROR_NAMES) + self.assertEqual(drv.describe_error_code(0), "NO_ERROR") + self.assertEqual(drv.describe_error_code(7), "errorCode=0x07") + self.assertEqual(drv.describe_error_code(255), "errorCode=0xff") + + +class LedEffectCodeTests(unittest.TestCase): + def test_codes_cover_all_effect_literals(self): + self.assertEqual( + set(_LED_EFFECT_CODES), + {"solid", "progress", "cylon", "rainbow", "blinking", "breathing"}, + ) + + def test_solid_is_zero(self): + self.assertEqual(_LED_EFFECT_CODES["solid"], 0x00) + + def test_codes_are_unique(self): + self.assertEqual(len(set(_LED_EFFECT_CODES.values())), len(_LED_EFFECT_CODES)) + + +class ByonoyDeviceEnumTests(unittest.TestCase): + def test_distinct_values(self): + self.assertNotEqual(ByonoyDevice.ABSORBANCE_96, ByonoyDevice.LUMINESCENCE_96) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index 0abae71942d..a64d339e4ef 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -38,7 +38,7 @@ class ByonoyLuminescence96Backend(ByonoyDriver, LuminescenceBackend): """Backend for the Byonoy Luminescence 96 Automate plate reader.""" def __init__(self) -> None: - super().__init__(pid=0x119B, device_type=ByonoyDevice.LUMINESCENCE_96) + super().__init__(pid=0x119B, device_type=ByonoyDevice.LUMINESCENCE_96, name="Byonoy L96") @dataclass class LuminescenceParams(BackendParams): @@ -50,17 +50,10 @@ class LuminescenceParams(BackendParams): 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 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 = "sensitive" integration_time: Optional[float] = None - selected_wells: Optional[List[bool]] = None async def read_luminescence( self, @@ -95,19 +88,16 @@ async def read_luminescence( 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] + # Firmware always scans all 96 wells; this mask only filters which are + # reported (others come back as 0.0). Single source of truth: the wells arg. + well_set = set(id(w) for w in wells) + mask_bools = [id(w) in well_set for w in plate.get_all_items()] well_mask = encode_well_bitmask(mask_bools, n=96) logger.info( - "[Byonoy L96 pid=0x%04X] reading luminescence: plate='%s', mode=%s, " + "[%s] reading luminescence: plate='%s', mode=%s, " "integration_time=%.3fs, wells=%d/96", - self.io.pid, + self.name, plate.name, mode, integration_time, @@ -144,13 +134,14 @@ async def read_luminescence( t0 = time.time() all_rows: List[Optional[float]] = [] + chunk_flags: List[int] = [] # vendor bit definitions unpublished; surface non-zero while True: if self._abort_requested: - logger.info("[Byonoy L96 pid=0x%04X] read aborted by cancel()", self.io.pid) + logger.info("[%s] read aborted by cancel()", self.name) 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) + logger.error("[%s] luminescence read timed out after 120s", self.name) raise TimeoutError("Reading luminescence data timed out after 2 minutes.") chunk = await self.io.read(64, timeout=2) @@ -166,19 +157,42 @@ async def read_luminescence( _ = reader.u32() # integration_time_us _ = reader.u32() # duration_ms row = [reader.f32() for _ in range(12)] - _ = reader.u8() # flags - _ = reader.u8() # progress + flags = reader.u8() + _ = reader.u8() # progress (0..100 running %); not surfaced all_rows.extend(row) + chunk_flags.append(flags) if seq == seq_len - 1: break - hybrid_result: List[Optional[float]] = all_rows[96 * 0 : 96 * 1] + # Check firmware health before trusting the data. error_code is the + # authoritative post-measurement status byte; per-chunk flags are + # undocumented but a non-zero value means the firmware flagged the chunk. + status = await self.request_status() + if status.error_code != 0: + raise RuntimeError( + f"{self.name} firmware error after read: " + f"{self.describe_error_code(status.error_code)} " + f"(chunk flags: {[f'0x{f:02x}' for f in chunk_flags]})" + ) + if any(f != 0 for f in chunk_flags): + logger.warning( + "[%s] non-zero chunk flags during read: %s " + "(vendor bit definitions not published; data may be unreliable)", + self.name, + [f"0x{f:02x}" for f in chunk_flags], + ) + assert len(all_rows) == 96, f"expected 96 luminescence values, got {len(all_rows)}" + + # Firmware zero-fills wells outside the mask. Convert those to None per + # the LuminescenceResult contract ("None for unmeasured wells") — 0.0 is + # a legitimate measurement (baseline subtraction can yield ~0 or negative). + masked: List[Optional[float]] = [v if m else None for v, m in zip(all_rows, mask_bools)] return [ LuminescenceResult( - data=reshape_2d(hybrid_result, (8, 12)), + data=reshape_2d(masked, (8, 12)), temperature=None, timestamp=time.time(), ) From 3a210287dfb2ff4b4761d24d0220e7967c9a2d4a Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 21:07:11 -0700 Subject: [PATCH 24/28] byonoy: ruff format + isort CI lint/format-check requires sorted imports and ruff-format output. Pure formatting cleanup; no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/__init__.py | 20 ++++++++++---------- pylabrobot/byonoy/absorbance_96.py | 2 +- pylabrobot/byonoy/backend.py | 17 +++++++++++------ pylabrobot/byonoy/backend_tests.py | 6 +++--- pylabrobot/byonoy/luminescence_96.py | 5 ++--- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/pylabrobot/byonoy/__init__.py b/pylabrobot/byonoy/__init__.py index 20d1dca32ba..29871600bf3 100644 --- a/pylabrobot/byonoy/__init__.py +++ b/pylabrobot/byonoy/__init__.py @@ -1,13 +1,3 @@ -from .backend import ( - Abs1StatusError, - Abs96StatusError, - ByonoyDeviceInfo, - ByonoyEnvironment, - ByonoyStatus, - ByonoyVersions, - LedEffect, - Lum96IntegrationMode, -) from .absorbance_96 import ( ByonoyAbsorbance96, ByonoyAbsorbance96Backend, @@ -18,6 +8,16 @@ byonoy_a96a_parking_unit, byonoy_sbs_adapter, ) +from .backend import ( + Abs1StatusError, + Abs96StatusError, + ByonoyDeviceInfo, + ByonoyEnvironment, + ByonoyStatus, + ByonoyVersions, + LedEffect, + Lum96IntegrationMode, +) from .luminescence_96 import ( ByonoyLuminescence96, ByonoyLuminescence96Backend, diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index aa43eaecf24..acec449f407 100644 --- a/pylabrobot/byonoy/absorbance_96.py +++ b/pylabrobot/byonoy/absorbance_96.py @@ -3,7 +3,7 @@ import time from typing import List, Optional, Tuple -from pylabrobot.byonoy.backend import ABS96_ERROR_NAMES, ByonoyDriver, ByonoyDevice +from pylabrobot.byonoy.backend import ABS96_ERROR_NAMES, ByonoyDevice, ByonoyDriver from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.absorbance import ( Absorbance, diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 01f45c00171..922ef884258 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -153,6 +153,7 @@ class Abs96StatusError(enum.IntEnum): 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 @@ -330,9 +331,7 @@ async def read_data_field(self, field_index: int) -> object: (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" - ) + 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 @@ -448,14 +447,18 @@ async def set_led_color( payload = ( Writer() .u8(_LED_EFFECT_CODES[effect]) - .u8(r_ & 0xFF).u8(g & 0xFF).u8(b & 0xFF) + .u8(r_ & 0xFF) + .u8(g & 0xFF) + .u8(b & 0xFF) .u8(effect_state & 0xFF) .u8(flags) .u32(int(duration_ms)) .finish() ) await self.send_command( - report_id=0x0351, payload=payload, wait_for_response=False, + report_id=0x0351, + payload=payload, + wait_for_response=False, routing_info=b"\x00\x40", ) @@ -472,7 +475,9 @@ async def set_led_colors(self, colors: List[Tuple[int, int, int]]) -> None: 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, + report_id=0x0350, + payload=w.finish(), + wait_for_response=False, routing_info=b"\x00\x40", ) diff --git a/pylabrobot/byonoy/backend_tests.py b/pylabrobot/byonoy/backend_tests.py index 7fd981f6022..8ac698bb912 100644 --- a/pylabrobot/byonoy/backend_tests.py +++ b/pylabrobot/byonoy/backend_tests.py @@ -1,15 +1,15 @@ import unittest from pylabrobot.byonoy.backend import ( + _GENERIC_ERROR_NAMES, + _LED_EFFECT_CODES, ABS1_ERROR_NAMES, ABS96_ERROR_NAMES, + LUM96_PRESET_S, Abs1StatusError, Abs96StatusError, ByonoyDevice, ByonoyDriver, - LUM96_PRESET_S, - _GENERIC_ERROR_NAMES, - _LED_EFFECT_CODES, encode_well_bitmask, ) diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index a64d339e4ef..db22a68271e 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -6,8 +6,8 @@ from pylabrobot.byonoy.backend import ( LUM96_PRESET_S, - ByonoyDriver, ByonoyDevice, + ByonoyDriver, Lum96IntegrationMode, encode_well_bitmask, ) @@ -95,8 +95,7 @@ async def read_luminescence( well_mask = encode_well_bitmask(mask_bools, n=96) logger.info( - "[%s] reading luminescence: plate='%s', mode=%s, " - "integration_time=%.3fs, wells=%d/96", + "[%s] reading luminescence: plate='%s', mode=%s, integration_time=%.3fs, wells=%d/96", self.name, plate.name, mode, From a29ff77824242eae160b76515168b5dff4485540 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 22:00:21 -0700 Subject: [PATCH 25/28] io: promote human_readable_device_name to a public attribute Drop the leading underscore on the instance attribute across all io.* backends (hid, ftdi, serial, socket, usb). The constructor parameter was already named `human_readable_device_name`; only the stored attribute was private. Now backends (and external callers) can read `self.io.human_readable_device_name` without reaching into a private name. Pure rename. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/io/ftdi.py | 8 ++++---- pylabrobot/io/hid.py | 8 ++++---- pylabrobot/io/serial.py | 20 ++++++++++---------- pylabrobot/io/socket.py | 16 ++++++++-------- pylabrobot/io/usb.py | 22 +++++++++++----------- 5 files changed, 37 insertions(+), 37 deletions(-) diff --git a/pylabrobot/io/ftdi.py b/pylabrobot/io/ftdi.py index ee967cc8ce2..e52cb286de4 100644 --- a/pylabrobot/io/ftdi.py +++ b/pylabrobot/io/ftdi.py @@ -74,7 +74,7 @@ def __init__( f"Import error: {_PYUSB_ERROR}" ) - self._human_readable_device_name = human_readable_device_name + self.human_readable_device_name = human_readable_device_name self._device_id = device_id self._vid = vid self._pid = pid @@ -86,7 +86,7 @@ def __init__( if get_capture_or_validation_active(): raise RuntimeError( - f"Cannot create a new FTDI object for '{self._human_readable_device_name}' while capture or validation is active" + f"Cannot create a new FTDI object for '{self.human_readable_device_name}' while capture or validation is active" ) @property @@ -198,7 +198,7 @@ async def setup(self): logger.info(f"Successfully opened FTDI device: {self.device_id}") except FtdiError as e: raise RuntimeError( - f"Failed to open FTDI device for '{self._human_readable_device_name}': {e}. " + f"Failed to open FTDI device for '{self.human_readable_device_name}': {e}. " "Is the device connected? Is it in use by another process? " "Try restarting the kernel." ) from e @@ -329,7 +329,7 @@ async def readline(self) -> bytes: # type: ignore # very dumb it's reading from def serialize(self): return { - "human_readable_device_name": self._human_readable_device_name, + "human_readable_device_name": self.human_readable_device_name, "device_id": self._device_id, "vid": self._vid, "pid": self._pid, diff --git a/pylabrobot/io/hid.py b/pylabrobot/io/hid.py index f6b24ff3a51..1b624da6962 100644 --- a/pylabrobot/io/hid.py +++ b/pylabrobot/io/hid.py @@ -32,7 +32,7 @@ class HID(IOBase): def __init__( self, human_readable_device_name: str, vid: int, pid: int, serial_number: Optional[str] = None ): - self._human_readable_device_name = human_readable_device_name + self.human_readable_device_name = human_readable_device_name self.vid = vid self.pid = pid self.serial_number = serial_number @@ -144,7 +144,7 @@ async def write(self, data: bytes, report_id: bytes = b"\x00"): def _write(): if self.device is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") return self.device.write(write_data) if self._executor is None: @@ -161,7 +161,7 @@ async def read(self, size: int, timeout: int) -> bytes: def _read(): if self.device is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") try: return self.device.read(size, timeout=int(timeout)) except HIDException as e: @@ -179,7 +179,7 @@ def _read(): def serialize(self): return { - "human_readable_device_name": self._human_readable_device_name, + "human_readable_device_name": self.human_readable_device_name, "vid": self.vid, "pid": self.pid, "serial_number": self.serial_number, diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index b8b5c085071..26ddf801d96 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -51,7 +51,7 @@ def __init__( dsrdtr: bool = False, xonxoff: bool = False, ): - self._human_readable_device_name = human_readable_device_name + self.human_readable_device_name = human_readable_device_name self._port = port self._vid = vid self._pid = pid @@ -202,7 +202,7 @@ def _open_serial() -> serial.Serial: except serial.SerialException as e: logger.error( - f"Could not connect to device '{self._human_readable_device_name}', is it in use by a different notebook/process?" + f"Could not connect to device '{self.human_readable_device_name}', is it in use by a different notebook/process?" ) if self._executor is not None: self._executor.shutdown(wait=True) @@ -220,7 +220,7 @@ async def stop(self): loop = asyncio.get_running_loop() if self._executor is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") await loop.run_in_executor(self._executor, self._ser.close) if self._executor is not None: @@ -232,7 +232,7 @@ async def write(self, data: bytes): loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") await loop.run_in_executor(self._executor, self._ser.write, data) @@ -246,7 +246,7 @@ async def read(self, num_bytes: int = 1) -> bytes: loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") data = await loop.run_in_executor(self._executor, self._ser.read, num_bytes) @@ -263,7 +263,7 @@ async def readline(self) -> bytes: # type: ignore # very dumb it's reading from loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") data = await loop.run_in_executor(self._executor, self._ser.readline) @@ -280,7 +280,7 @@ async def send_break(self, duration: float): loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") def _send_break(ser, duration: float) -> None: """Send a break condition for the specified duration.""" @@ -294,7 +294,7 @@ def _send_break(ser, duration: float) -> None: async def reset_input_buffer(self): loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") await loop.run_in_executor(self._executor, self._ser.reset_input_buffer) logger.log(LOG_LEVEL_IO, "[%s] reset_input_buffer", self._port) capturer.record(SerialCommand(device_id=self.port, action="reset_input_buffer", data="")) @@ -302,7 +302,7 @@ async def reset_input_buffer(self): async def reset_output_buffer(self): loop = asyncio.get_running_loop() if self._executor is None or self._ser is None: - raise RuntimeError(f"Call setup() first for device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for device '{self.human_readable_device_name}'.") await loop.run_in_executor(self._executor, self._ser.reset_output_buffer) logger.log(LOG_LEVEL_IO, "[%s] reset_output_buffer", self._port) capturer.record(SerialCommand(device_id=self.port, action="reset_output_buffer", data="")) @@ -341,7 +341,7 @@ def rts(self, value: bool): def serialize(self): return { - "human_readable_device_name": self._human_readable_device_name, + "human_readable_device_name": self.human_readable_device_name, "port": self._port, "baudrate": self.baudrate, "bytesize": self.bytesize, diff --git a/pylabrobot/io/socket.py b/pylabrobot/io/socket.py index 575cb4d7c1d..ae465fa3cfa 100644 --- a/pylabrobot/io/socket.py +++ b/pylabrobot/io/socket.py @@ -38,7 +38,7 @@ def __init__( ssl_context: Optional[ssl.SSLContext] = None, server_hostname: Optional[str] = None, ): - self._human_readable_device_name = human_readable_device_name + self.human_readable_device_name = human_readable_device_name self._host = host self._port = port self._reader: Optional[asyncio.StreamReader] = None @@ -91,7 +91,7 @@ async def reconnect(self): def serialize(self): return { - "human_readable_device_name": self._human_readable_device_name, + "human_readable_device_name": self.human_readable_device_name, "host": self._host, "port": self._port, "type": "Socket", @@ -105,7 +105,7 @@ async def write(self, data: bytes, timeout: Optional[float] = None) -> None: """ if self._writer is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) timeout = self._write_timeout if timeout is None else timeout async with self._write_lock: @@ -142,7 +142,7 @@ async def read(self, num_bytes: int = 128, timeout: Optional[float] = None) -> b """ if self._reader is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) timeout = self._read_timeout if timeout is None else timeout async with self._read_lock: @@ -165,7 +165,7 @@ async def readline(self, timeout: Optional[float] = None) -> bytes: """Wrapper around StreamReader.readline with lock and io logging.""" if self._reader is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) timeout = self._read_timeout if timeout is None else timeout async with self._read_lock: @@ -189,7 +189,7 @@ async def readuntil(self, separator: bytes = b"\n", timeout: Optional[float] = N Do not retry on timeouts.""" if self._reader is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) timeout = self._read_timeout if timeout is None else timeout async with self._read_lock: @@ -227,7 +227,7 @@ async def read_exact(self, num_bytes: int, timeout: Optional[float] = None) -> b """ if self._reader is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) timeout = self._read_timeout if timeout is None else timeout data = bytearray() @@ -265,7 +265,7 @@ async def read_until_eof(self, chunk_size: int = 1024, timeout: Optional[float] while True: if self._reader is None: raise RuntimeError( - f"Socket for '{self._human_readable_device_name}' not set up; call setup() first" + f"Socket for '{self.human_readable_device_name}' not set up; call setup() first" ) try: chunk = await asyncio.wait_for(self._reader.read(chunk_size), timeout=timeout) diff --git a/pylabrobot/io/usb.py b/pylabrobot/io/usb.py index a0b4bcba101..ab54e8c53de 100644 --- a/pylabrobot/io/usb.py +++ b/pylabrobot/io/usb.py @@ -106,7 +106,7 @@ def __init__( # unique id in the logs self._unique_id = f"[{hex(self._id_vendor)}:{hex(self._id_product)}][{self._serial_number or ''}][{self._device_address or ''}]" - self._human_readable_device_name = human_readable_device_name + self.human_readable_device_name = human_readable_device_name async def write(self, data: bytes, timeout: Optional[float] = None): """Write data to the device. @@ -118,7 +118,7 @@ async def write(self, data: bytes, timeout: Optional[float] = None): """ if self.dev is None or self.read_endpoint is None: - raise RuntimeError(f"USB device for '{self._human_readable_device_name}' is not connected.") + raise RuntimeError(f"USB device for '{self.human_readable_device_name}' is not connected.") if timeout is None: timeout = self.write_timeout @@ -128,7 +128,7 @@ async def write(self, data: bytes, timeout: Optional[float] = None): write_endpoint = self.write_endpoint dev = self.dev if self._executor is None or dev is None or write_endpoint is None: - raise RuntimeError(f"Call setup() first for USB device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for USB device '{self.human_readable_device_name}'.") await loop.run_in_executor( self._executor, lambda: dev.write( @@ -169,7 +169,7 @@ def _read_packet( """ if self.dev is None or self.read_endpoint is None: - raise RuntimeError(f"USB device for '{self._human_readable_device_name}' is not connected.") + raise RuntimeError(f"USB device for '{self.human_readable_device_name}' is not connected.") ep = endpoint if endpoint is not None else self.read_endpoint if ep is None: @@ -221,7 +221,7 @@ async def read(self, timeout: Optional[int] = None, size: Optional[int] = None) """ if self.dev is None or self.read_endpoint is None: - raise RuntimeError(f"USB device for '{self._human_readable_device_name}' is not connected.") + raise RuntimeError(f"USB device for '{self.human_readable_device_name}' is not connected.") if timeout is None: timeout = self.read_timeout @@ -261,12 +261,12 @@ def read_or_timeout(): return resp raise TimeoutError( - f"Timeout while reading from USB device '{self._human_readable_device_name}'." + f"Timeout while reading from USB device '{self.human_readable_device_name}'." ) loop = asyncio.get_running_loop() if self._executor is None or self.dev is None: - raise RuntimeError(f"Call setup() first for USB device '{self._human_readable_device_name}'.") + raise RuntimeError(f"Call setup() first for USB device '{self.human_readable_device_name}'.") return await loop.run_in_executor(self._executor, read_or_timeout) def get_available_devices(self) -> List["usb.core.Device"]: @@ -283,7 +283,7 @@ def get_available_devices(self) -> List["usb.core.Device"]: if self._device_address is not None: if dev.address is None: raise RuntimeError( - f"A device address was specified for '{self._human_readable_device_name}', but the backend used for PyUSB does " + f"A device address was specified for '{self.human_readable_device_name}', but the backend used for PyUSB does " "not support device addresses." ) @@ -293,7 +293,7 @@ def get_available_devices(self) -> List["usb.core.Device"]: if self._serial_number is not None: if dev._serial_number is None: raise RuntimeError( - f"A serial number was specified for '{self._human_readable_device_name}', but the device does not have a serial number." + f"A serial number was specified for '{self.human_readable_device_name}', but the device does not have a serial number." ) if dev.serial_number != self._serial_number: @@ -331,7 +331,7 @@ def ctrl_transfer( timeout: Optional[int] = None, ) -> bytearray: if self.dev is None: - raise RuntimeError(f"USB device for '{self._human_readable_device_name}' is not connected.") + raise RuntimeError(f"USB device for '{self.human_readable_device_name}' is not connected.") if timeout is None: timeout = self.read_timeout @@ -467,7 +467,7 @@ def serialize(self) -> dict: d = { **super().serialize(), - "human_readable_device_name": self._human_readable_device_name, + "human_readable_device_name": self.human_readable_device_name, "id_vendor": self._id_vendor, "id_product": self._id_product, "device_address": self._device_address, From ad565b187f663d27e8ffcc265e662f610c3e6fae Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 16 May 2026 22:00:44 -0700 Subject: [PATCH 26/28] byonoy: address second-pass review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol/correctness: - assert len(all_rows) == 96 → raise RuntimeError (stripped under -O) - assert wavelength in available_wavelengths → raise ValueError - read loops: index chunks by seq into rows_by_seq[seq_len] and validate per-chunk (reject seq_len=0, seq_len mid-stream change, out-of-range seq). Dropped or reordered firmware chunks now raise instead of silently writing 12 floats into the wrong plate row. - stop(): cancel in-flight measurement before closing the HID handle so the read coroutine doesn't fail with "Call setup() first" - send_command: serialize with an asyncio.Lock so two callers can't interleave HID frames. Read loops poll io.read directly outside the lock so cancel() can still send the abort while a measurement runs. API: - request_api_version() returns int (was wrapper dataclass with one field) - read_data_field → _read_data_field (only request_device_info calls it) - extract ByonoyDriver._warn_chunk_flags helper to dedup the read loops - __init__.py: re-export LuminescenceParams (top-level alias), LUM96_PRESET_S, ByonoyDevice, ByonoySlotState, encode_well_bitmask - ByonoyDriver.name now reads io.human_readable_device_name (public) Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/__init__.py | 7 +++ pylabrobot/byonoy/absorbance_96.py | 47 ++++++++++------ pylabrobot/byonoy/backend.py | 84 ++++++++++++++++++---------- pylabrobot/byonoy/luminescence_96.py | 43 +++++++++----- 4 files changed, 121 insertions(+), 60 deletions(-) diff --git a/pylabrobot/byonoy/__init__.py b/pylabrobot/byonoy/__init__.py index 29871600bf3..a5740e23289 100644 --- a/pylabrobot/byonoy/__init__.py +++ b/pylabrobot/byonoy/__init__.py @@ -9,14 +9,18 @@ byonoy_sbs_adapter, ) from .backend import ( + LUM96_PRESET_S, Abs1StatusError, Abs96StatusError, + ByonoyDevice, ByonoyDeviceInfo, ByonoyEnvironment, + ByonoySlotState, ByonoyStatus, ByonoyVersions, LedEffect, Lum96IntegrationMode, + encode_well_bitmask, ) from .luminescence_96 import ( ByonoyLuminescence96, @@ -29,3 +33,6 @@ byonoy_l96a_base_unit, byonoy_l96a_reader_unit, ) + +# Convenience alias so users don't reach into the nested class. +LuminescenceParams = ByonoyLuminescence96Backend.LuminescenceParams diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index acec449f407..be8efb6fb04 100644 --- a/pylabrobot/byonoy/absorbance_96.py +++ b/pylabrobot/byonoy/absorbance_96.py @@ -81,8 +81,11 @@ async def _run_abs_measurement(self, signal_wl: int, reference_wl: int, is_refer routing_info=b"\x00\x40", ) - rows: List[float] = [] - chunk_flags: List[int] = [] # vendor bit definitions unpublished; surface non-zero + # Index by seq so out-of-order/dropped chunks surface as None slots + # rather than silently shifting subsequent rows into the wrong wells. + rows_by_seq: List[Optional[List[float]]] = [] + flags_by_seq: List[Optional[int]] = [] + expected_chunks: Optional[int] = None t0 = time.time() while True: @@ -115,12 +118,29 @@ async def _run_abs_measurement(self, signal_wl: int, reference_wl: int, is_refer flags = reader.u8() _ = reader.u8() # progress (0..100 running %); not surfaced - rows.extend(row) - chunk_flags.append(flags) - - if seq == seq_len - 1: + if seq_len == 0: + raise RuntimeError(f"{self.name} firmware sent chunk with seq_len=0") + if expected_chunks is None: + expected_chunks = seq_len + rows_by_seq = [None] * seq_len + flags_by_seq = [None] * seq_len + elif seq_len != expected_chunks: + raise RuntimeError( + f"{self.name} firmware changed seq_len mid-stream: {expected_chunks} → {seq_len}" + ) + if not 0 <= seq < seq_len: + raise RuntimeError(f"{self.name} firmware sent seq={seq} (seq_len={seq_len})") + rows_by_seq[seq] = row + flags_by_seq[seq] = flags + + if all(r is not None for r in rows_by_seq): break + if expected_chunks is None: + raise RuntimeError(f"{self.name} absorbance read produced no chunks") + chunk_flags: List[int] = [f for f in flags_by_seq if f is not None] + rows: List[float] = [v for r in rows_by_seq if r is not None for v in r] + status = await self.request_status() if status.error_code != 0: raise RuntimeError( @@ -128,13 +148,7 @@ async def _run_abs_measurement(self, signal_wl: int, reference_wl: int, is_refer f"ref={reference_wl} nm): {self.describe_error_code(status.error_code)} " f"(chunk flags: {[f'0x{f:02x}' for f in chunk_flags]})" ) - if any(f != 0 for f in chunk_flags): - logger.warning( - "[%s] non-zero chunk flags during measurement: %s " - "(vendor bit definitions not published; data may be unreliable)", - self.name, - [f"0x{f:02x}" for f in chunk_flags], - ) + self._warn_chunk_flags(chunk_flags) return rows @@ -154,9 +168,10 @@ async def read_absorbance( wavelength: int, backend_params: Optional[SerializableMixin] = None, ) -> List[AbsorbanceResult]: - assert wavelength in self.available_wavelengths, ( - f"Wavelength {wavelength} nm not in available wavelengths {self.available_wavelengths}." - ) + if wavelength not in self.available_wavelengths: + raise ValueError( + f"Wavelength {wavelength} nm not in available wavelengths {self.available_wavelengths}." + ) logger.info( "[%s] reading absorbance: plate='%s', wavelength=%d nm, wells=%d/%d", diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 922ef884258..be866b370e1 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -1,3 +1,4 @@ +import asyncio import contextlib import enum import logging @@ -84,11 +85,6 @@ def is_production(self) -> bool: return self.stm_dev_version == 0 and self.esp_dev_version == 0 -@dataclass -class ByonoyApiVersion: - version_no: int - - @dataclass class ByonoyDeviceInfo: device_id: str @@ -188,16 +184,22 @@ def __init__(self, pid: int, device_type: ByonoyDevice, name: str) -> None: self._device_type = device_type self._abort_requested = False self._in_flight_trigger: Optional[int] = None + # Serializes write+response-sniff in send_command. Does NOT cover the + # measurement read loops in subclasses (which poll io.read directly so + # cancel() can still send the abort while they run). + self._io_lock = asyncio.Lock() @property def name(self) -> str: - return self.io._human_readable_device_name + return self.io.human_readable_device_name async def setup(self, backend_params: Optional[BackendParams] = None) -> None: await self.io.setup() logger.info("[%s] connected", self.name) async def stop(self) -> None: + if self._in_flight_trigger is not None: + await self.cancel() await self.io.stop() logger.info("[%s] disconnected", self.name) @@ -214,26 +216,27 @@ async def send_command( routing_info: bytes = b"\x00\x00", ) -> Optional[bytes]: command = self._assemble_command(report_id, payload=payload, routing_info=routing_info) - await self.io.write(command) - if not wait_for_response: - return None - - t0 = time.time() - while True: - if time.time() - t0 > 120: - logger.error( - "[%s] timeout waiting for response to command 0x%04X after 120s", - self.name, - report_id, - ) - raise TimeoutError("Reading data timed out after 2 minutes.") - response = await self.io.read(64, timeout=30) - if len(response) == 0: - continue - response_report_id = Reader(response).u16() - if report_id == response_report_id: - break - return response + async with self._io_lock: + await self.io.write(command) + if not wait_for_response: + return None + + t0 = time.time() + while True: + if time.time() - t0 > 120: + logger.error( + "[%s] timeout waiting for response to command 0x%04X after 120s", + self.name, + report_id, + ) + raise TimeoutError("Reading data timed out after 2 minutes.") + response = await self.io.read(64, timeout=30) + if len(response) == 0: + continue + response_report_id = Reader(response).u16() + if report_id == response_report_id: + break + return response async def request_status(self) -> ByonoyStatus: """Read REP_STATUS_IN (0x0300): init/slot/error/uptime/measuring/boot.""" @@ -251,6 +254,22 @@ async def request_status(self) -> ByonoyStatus: boot_completed=r.u8() != 0, ) + def _warn_chunk_flags(self, chunk_flags: List[int]) -> None: + """Log non-zero per-chunk flag bytes from a measurement read loop. + + Vendor bit definitions for the measurement-result `flags` byte aren't + published, so we can't decode them — only surface that *something* was + flagged. Subclasses' read loops call this after the loop completes + (after error_code has been checked and didn't raise). + """ + if any(f != 0 for f in chunk_flags): + logger.warning( + "[%s] non-zero chunk flags during read: %s " + "(vendor bit definitions not published; data may be unreliable)", + self.name, + [f"0x{f:02x}" for f in chunk_flags], + ) + def describe_error_code(self, code: int) -> str: """Return a human-readable name for a firmware error_code byte. @@ -281,14 +300,13 @@ async def request_environment(self) -> ByonoyEnvironment: acceleration_g=(ax / _ACCEL_LSB_PER_G, ay / _ACCEL_LSB_PER_G, az / _ACCEL_LSB_PER_G), ) - async def request_api_version(self) -> ByonoyApiVersion: + async def request_api_version(self) -> int: """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()) + return Reader(response[2:]).u32() async def request_supported_reports(self) -> List[int]: """Read REP_SUPPORTED_REPORTS_IN (0x0010): list of report IDs the device supports. @@ -323,12 +341,16 @@ async def request_supported_reports(self) -> List[int]: out.append(i) return out - async def read_data_field(self, field_index: int) -> object: + 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). + + Private — the only documented caller is `request_device_info`, which knows + the field types ahead of time. Promote to public if you find a use case + that needs the polymorphic-by-flag-byte shape. """ 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") @@ -358,7 +380,7 @@ async def request_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) + v = await self._read_data_field(idx) return v if isinstance(v, str) else str(v) return ByonoyDeviceInfo( diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index db22a68271e..52617a5c1c1 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -132,8 +132,11 @@ async def read_luminescence( ) t0 = time.time() - all_rows: List[Optional[float]] = [] - chunk_flags: List[int] = [] # vendor bit definitions unpublished; surface non-zero + # Index by seq so an out-of-order or dropped chunk surfaces as a None + # slot instead of silently shifting subsequent rows into the wrong wells. + rows_by_seq: List[Optional[List[float]]] = [] + flags_by_seq: List[Optional[int]] = [] + expected_chunks: Optional[int] = None while True: if self._abort_requested: @@ -159,12 +162,29 @@ async def read_luminescence( flags = reader.u8() _ = reader.u8() # progress (0..100 running %); not surfaced - all_rows.extend(row) - chunk_flags.append(flags) - - if seq == seq_len - 1: + if seq_len == 0: + raise RuntimeError(f"{self.name} firmware sent chunk with seq_len=0") + if expected_chunks is None: + expected_chunks = seq_len + rows_by_seq = [None] * seq_len + flags_by_seq = [None] * seq_len + elif seq_len != expected_chunks: + raise RuntimeError( + f"{self.name} firmware changed seq_len mid-stream: {expected_chunks} → {seq_len}" + ) + if not 0 <= seq < seq_len: + raise RuntimeError(f"{self.name} firmware sent seq={seq} (seq_len={seq_len})") + rows_by_seq[seq] = row + flags_by_seq[seq] = flags + + if all(r is not None for r in rows_by_seq): break + if expected_chunks is None: + raise RuntimeError(f"{self.name} luminescence read produced no chunks") + chunk_flags: List[int] = [f for f in flags_by_seq if f is not None] + all_rows: List[float] = [v for row in rows_by_seq if row is not None for v in row] + # Check firmware health before trusting the data. error_code is the # authoritative post-measurement status byte; per-chunk flags are # undocumented but a non-zero value means the firmware flagged the chunk. @@ -175,14 +195,11 @@ async def read_luminescence( f"{self.describe_error_code(status.error_code)} " f"(chunk flags: {[f'0x{f:02x}' for f in chunk_flags]})" ) - if any(f != 0 for f in chunk_flags): - logger.warning( - "[%s] non-zero chunk flags during read: %s " - "(vendor bit definitions not published; data may be unreliable)", - self.name, - [f"0x{f:02x}" for f in chunk_flags], + self._warn_chunk_flags(chunk_flags) + if len(all_rows) != 96: + raise RuntimeError( + f"{self.name} luminescence read produced {len(all_rows)} values (expected 96)" ) - assert len(all_rows) == 96, f"expected 96 luminescence values, got {len(all_rows)}" # Firmware zero-fills wells outside the mask. Convert those to None per # the LuminescenceResult contract ("None for unmeasured wells") — 0.0 is From 027fcc92f56ea576a62513352278b165a28d8c35 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 17 May 2026 12:50:12 -0700 Subject: [PATCH 27/28] byonoy: drop LuminescenceParams top-level alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical access path is ByonoyLuminescence96Backend.LuminescenceParams. Nesting params inside the backend is intentional — alias was redundant. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/byonoy/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/pylabrobot/byonoy/__init__.py b/pylabrobot/byonoy/__init__.py index a5740e23289..4e85893800a 100644 --- a/pylabrobot/byonoy/__init__.py +++ b/pylabrobot/byonoy/__init__.py @@ -33,6 +33,3 @@ byonoy_l96a_base_unit, byonoy_l96a_reader_unit, ) - -# Convenience alias so users don't reach into the nested class. -LuminescenceParams = ByonoyLuminescence96Backend.LuminescenceParams From aff4136468317174a5b982d81c8515ba1d32be0f Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 17 May 2026 13:46:44 -0700 Subject: [PATCH 28/28] =?UTF-8?q?byonoy:=20rename=20backend.py=20=E2=86=92?= =?UTF-8?q?=20driver.py=20+=20docs=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pylabrobot/byonoy/backend.py → driver.py (and backend_tests.py → driver_tests.py). Imports updated; behavior unchanged. Docs: - index.md: add led_bar.ipynb to toctree (was orphaned) - index.md: add Absorbance 96 models table (mirror of Luminescence 96 table); strip the same table from absorbance_96/hello-world.ipynb - luminescence_96/hello-world.ipynb: drop \"a heartbeat thread is running\" (ping worker was removed); fix api.version_no → api (request_api_version now returns int) - luminescence_96/led_bar.ipynb: update Source: ref to driver.py; strip stale execution outputs that named the old module path / pid hex Co-Authored-By: Claude Opus 4.7 (1M context) --- .../byonoy/absorbance_96/hello-world.ipynb | 2 +- docs/user_guide/byonoy/index.md | 11 +++++++++ .../byonoy/luminescence_96/hello-world.ipynb | 4 ++-- .../byonoy/luminescence_96/led_bar.ipynb | 24 ++++--------------- pylabrobot/byonoy/__init__.py | 2 +- pylabrobot/byonoy/absorbance_96.py | 2 +- pylabrobot/byonoy/{backend.py => driver.py} | 0 .../{backend_tests.py => driver_tests.py} | 2 +- pylabrobot/byonoy/luminescence_96.py | 2 +- 9 files changed, 22 insertions(+), 27 deletions(-) rename pylabrobot/byonoy/{backend.py => driver.py} (100%) rename pylabrobot/byonoy/{backend_tests.py => driver_tests.py} (99%) diff --git a/docs/user_guide/byonoy/absorbance_96/hello-world.ipynb b/docs/user_guide/byonoy/absorbance_96/hello-world.ipynb index b1255e4f78b..4a076e63455 100644 --- a/docs/user_guide/byonoy/absorbance_96/hello-world.ipynb +++ b/docs/user_guide/byonoy/absorbance_96/hello-world.ipynb @@ -3,7 +3,7 @@ { "cell_type": "markdown", "id": "n0a5pl74u0o", - "source": "# Byonoy Absorbance 96\n\nThe Absorbance 96 Automate (A96A) is a USB-HID plate reader from Byonoy that measures absorbance across a 96-well plate in a single flash. It supports:\n\n- [Absorbance](../../capabilities/absorbance) (single-wavelength, full-plate)\n\nThe hardware consists of three physical parts: a **base unit** (holds the plate), an **illumination unit** (light source, sits on top during measurement), and an optional **SBS adapter** for standard footprint integration. PLR models all three as resources so a robotic arm can move the illumination unit on and off the base.\n\n| Model | PLR Name | Factory function |\n|---|---|---|\n| Absorbance 96 Automate (full setup) | `ByonoyAbsorbance96` | `byonoy_a96a` |\n| Detection unit only | `ByonoyAbsorbance96` | `byonoy_a96a_detection_unit` |\n| Illumination unit | `Resource` | `byonoy_a96a_illumination_unit` |\n| Parking base (no backend) | `ByonoyAbsorbanceBaseUnit` | `byonoy_a96a_parking_unit` |\n| SBS adapter | `ResourceHolder` | `byonoy_sbs_adapter` |\n\n- **Communication**: USB HID (VID `0x16D0` / PID `0x1199`)\n- **Communication level**: Firmware", + "source": "# Byonoy Absorbance 96\n\nThe Absorbance 96 Automate (A96A) is a USB-HID plate reader from Byonoy that measures absorbance across a 96-well plate in a single flash. It supports:\n\n- [Absorbance](../../capabilities/absorbance) (single-wavelength, full-plate)\n\nThe hardware consists of three physical parts: a **base unit** (holds the plate), an **illumination unit** (light source, sits on top during measurement), and an optional **SBS adapter** for standard footprint integration. PLR models all three as resources so a robotic arm can move the illumination unit on and off the base.\n\n- **Communication**: USB HID (VID `0x16D0` / PID `0x1199`)\n- **Communication level**: Firmware", "metadata": {} }, { diff --git a/docs/user_guide/byonoy/index.md b/docs/user_guide/byonoy/index.md index 0049a510c70..53ad0851c69 100644 --- a/docs/user_guide/byonoy/index.md +++ b/docs/user_guide/byonoy/index.md @@ -5,8 +5,19 @@ absorbance_96/hello-world luminescence_96/hello-world +luminescence_96/led_bar ``` +## Absorbance 96 models + +| Model | PLR resource | Factory function | +|---|---|---| +| A96A full setup | `ByonoyAbsorbance96` + illumination unit | `byonoy_a96a` | +| Detection unit only | `ByonoyAbsorbance96` | `byonoy_a96a_detection_unit` | +| Illumination unit | `Resource` | `byonoy_a96a_illumination_unit` | +| Parking base (no backend) | `ByonoyAbsorbanceBaseUnit` | `byonoy_a96a_parking_unit` | +| SBS adapter | `ResourceHolder` | `byonoy_sbs_adapter` | + ## Luminescence 96 models | Model | PLR resource | Factory function | diff --git a/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb index c5a7b68a172..6c41dc7c144 100644 --- a/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb +++ b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb @@ -19,7 +19,7 @@ "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", + "`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.\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." ] @@ -294,7 +294,7 @@ "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\" api v{api}, fw production={versions.is_production}\")\n", "print(f\" supported reports ({len(supported)}): \" + \", \".join(f\"0x{i:04X}\" for i in supported))" ] }, diff --git a/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb b/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb index f5977214c54..5306306c5ea 100644 --- a/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb +++ b/docs/user_guide/byonoy/luminescence_96/led_bar.ipynb @@ -37,15 +37,7 @@ "shell.execute_reply": "2026-05-17T02:44:03.306740Z" } }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-05-16 19:53:20,899 - pylabrobot.byonoy.backend - INFO - [Byonoy LUMINESCENCE_96 pid=0x119B] connected\n" - ] - } - ], + "outputs": [], "source": [ "from pylabrobot.byonoy import byonoy_l96\n", "\n", @@ -276,15 +268,7 @@ "shell.execute_reply": "2026-05-17T02:44:33.620597Z" } }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-05-16 19:53:51,258 - pylabrobot.byonoy.backend - INFO - [Byonoy LUMINESCENCE_96 pid=0x119B] disconnected\n" - ] - } - ], + "outputs": [], "source": [ "await drv.set_led_color((0, 0, 0)) # ensure bar is off\n", "await reader.stop()" @@ -299,7 +283,7 @@ "\n", "- `set_led_color(color, effect, *, duration_ms=0, force=False, low_power=False)` — single uniform color, optional firmware-driven effect.\n", "- `set_led_colors(colors)` — list of up to 20 `(r, g, b)` triplets, one per pixel. Pads with black; truncates if longer.\n", - "- Both methods live on `reader.driver`. Source: `pylabrobot/byonoy/backend.py`." + "- Both methods live on `reader.driver`. Source: `pylabrobot/byonoy/driver.py`." ] } ], @@ -324,4 +308,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/pylabrobot/byonoy/__init__.py b/pylabrobot/byonoy/__init__.py index 4e85893800a..a81b32658e2 100644 --- a/pylabrobot/byonoy/__init__.py +++ b/pylabrobot/byonoy/__init__.py @@ -8,7 +8,7 @@ byonoy_a96a_parking_unit, byonoy_sbs_adapter, ) -from .backend import ( +from .driver import ( LUM96_PRESET_S, Abs1StatusError, Abs96StatusError, diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index be8efb6fb04..a6fcc9bc812 100644 --- a/pylabrobot/byonoy/absorbance_96.py +++ b/pylabrobot/byonoy/absorbance_96.py @@ -3,7 +3,7 @@ import time from typing import List, Optional, Tuple -from pylabrobot.byonoy.backend import ABS96_ERROR_NAMES, ByonoyDevice, ByonoyDriver +from pylabrobot.byonoy.driver import ABS96_ERROR_NAMES, ByonoyDevice, ByonoyDriver from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.absorbance import ( Absorbance, diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/driver.py similarity index 100% rename from pylabrobot/byonoy/backend.py rename to pylabrobot/byonoy/driver.py diff --git a/pylabrobot/byonoy/backend_tests.py b/pylabrobot/byonoy/driver_tests.py similarity index 99% rename from pylabrobot/byonoy/backend_tests.py rename to pylabrobot/byonoy/driver_tests.py index 8ac698bb912..4480ddd3ae2 100644 --- a/pylabrobot/byonoy/backend_tests.py +++ b/pylabrobot/byonoy/driver_tests.py @@ -1,6 +1,6 @@ import unittest -from pylabrobot.byonoy.backend import ( +from pylabrobot.byonoy.driver import ( _GENERIC_ERROR_NAMES, _LED_EFFECT_CODES, ABS1_ERROR_NAMES, diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index 52617a5c1c1..c89027f3717 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import List, Optional, Tuple -from pylabrobot.byonoy.backend import ( +from pylabrobot.byonoy.driver import ( LUM96_PRESET_S, ByonoyDevice, ByonoyDriver,