Skip to content

Byonoy L96: device queries, integration modes, cancel, LED bar#1027

Open
vcjdeboer wants to merge 14 commits intoPyLabRobot:v1b1from
vcjdeboer:byonoy-luminescence
Open

Byonoy L96: device queries, integration modes, cancel, LED bar#1027
vcjdeboer wants to merge 14 commits intoPyLabRobot:v1b1from
vcjdeboer:byonoy-luminescence

Conversation

@vcjdeboer
Copy link
Copy Markdown
Contributor

Summary

Extends pylabrobot/byonoy to drive the full HID surface the Luminescence 96 firmware advertises — beyond the existing read_luminescence(integration_time), this PR adds device queries, integration-mode presets, per-well selection, software-cancellable reads, named-data-fields identity reads, LED bar control, and per-backend firmware-error decoding. Every byte is decoded from the headers in Byonoy's published C library; the vendor wheel is not a runtime dependency.

Validated on hardware against an L96 (sn BYOMAL00029, fw Luminescence V1 2025-03-24).

What's new

Public surface on ByonoyBase (Driver):

  • get_status() / get_environment() / get_versions() / get_api_version() / get_supported_reports() / get_device_info() — device queries via REP_*_IN reports (0x0080, 0x0050, 0x0010, 0x0300, 0x0310) and REP_DEVICE_DATA_READ_IN (0x0200) with the documented routing_info=\x80\x40 request tag.
  • read_data_field(field_index) — generic typed read of any DD_* field.
  • cancel(report_id=0x0340) — fires REP_ABORT_REPORT_OUT (0x0060) and raises a software flag the read loop polls; bails within ~2 s instead of the previous 120 s hard timeout.
  • set_led_colours(colours) / set_led_effect(effect, ...) — drives the 20-pixel front bar via REP_LED_BAR_COLOURS_OUT / REP_LED_BAR_EFFECTS_OUT.
  • describe_error_code(code) — overridable per-backend decoder.

LuminescenceParams now mirrors the C-library shape:

  • mode: Lum96IntegrationMode (RAPID 100 ms / SENSITIVE 2 s / ULTRA_SENSITIVE 20 s / CUSTOM)
  • integration_time: Optional[float] (forces CUSTOM if set; preserves the legacy call shape)
  • selected_wells: Optional[List[bool]] — output filter (firmware always scans all 96, zero-fills unselected wells; doesn't reduce read time).

Per-backend error tables: Abs96StatusError / Abs1StatusError enums mirroring hid-reports/.../abs96status.cpp and abs1status.cpp. ByonoyAbsorbance96Backend opts in via _ERROR_NAMES = ABS96_ERROR_NAMES; Lum96 has no documented table and inherits the honest hex sentinel fallback (errorCode=0xNN) — the same answer Byonoy's own software gives.

Docs:

  • docs/user_guide/byonoy/luminescence_96/lab_guide.md — 13-section walkthrough for someone running an actual luminescence assay (single read, kinetic series, custom integration, well selection, cancel, LED feedback, troubleshooting table).
  • pylabrobot/byonoy/ARCHITECTURE_NOTES.md — captures the v1b1-capability review so a future Driver / CapabilityBackend split has the context.

Test plan

  • from pylabrobot.byonoy import byonoy_l96; await reader.setup() against a physical L96 — connects via HID, no errors.
  • All six device queries return sane values (uptime, temperature, humidity, accelerometer at ~1 g down, firmware string).
  • get_supported_reports() returns 23 IDs matching the firmware's published feature set.
  • RAPID / SENSITIVE / ULTRA_SENSITIVE modes all complete with appropriate wall-clock times.
  • selected_wells mask: column-1-only read returns column 0 with real data and columns 1-11 as exactly 0.00. Read time is unchanged from a full-plate read at the same mode (firmware behaviour, documented).
  • cancel() aborts an ULTRA_SENSITIVE read within ~1 s (was 120 s). Device returns to is_measuring=False immediately after.
  • set_led_colours and set_led_effect produce the expected visual changes on the front bar.
  • get_device_info() returns the same identity fields the C library's byonoy_get_device_information returns.

Out of scope

The pre-existing collapse of Driver and CapabilityBackend into one class (ByonoyBase) is documented in ARCHITECTURE_NOTES.md but not addressed here — that refactor is independent and benefits from being a single focused PR. The notes propose ByonoyDriver + ByonoyDiagnostics / ByonoyLEDBar helpers (P-16 STARCover pattern), with concrete shape suggestions and v1b1 precedent paths.

🤖 Generated with Claude Code

vcjdeboer and others added 13 commits May 5, 2026 14:06
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@rickwierenga rickwierenga left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a great improvement!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you make this into a notebook so people can run it directly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, converted to lab_guide.ipynb

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could LED be its own capability / backend?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(A) Literal LEDBar capability with set_colours/set_effect as the
frontend. Helper-with-a-capability-label. Other devices with chassis
indicators (Flex, Synergy, Cytation) can't realistically fake a 20-pixel
RGB bar.

(B) StatusIndicator paired with InstrumentStatus — transferable,
both new to PLR:

# pylabrobot/capabilities/instrument_status/  (new)                                                                                                                                                       
class InstrumentState(enum.Enum):                         
    OFF; IDLE; BUSY; WAITING; SUCCESS; ERROR                                                                                                                                                              
                                                                                                                                                                                                          
class InstrumentStatusBackend(ABC):                                                                                                                                                                       
    @abstractmethod                                                                                                                                                                                       
    async def read_status(self) -> InstrumentStatusReading: ...                                                                                                                                           
 
# pylabrobot/capabilities/status_indicator/  (new)                                                                                                                                                        
class StatusIndicatorBackend(ABC):                        
    @abstractmethod                                                                                                                                                                                       
    async def set_state(self, state: InstrumentState,
                        progress: Optional[float] = None) -> None: ...                                                                                                                                    
                                                          
# pylabrobot/byonoy/  (new concrete backends)
class ByonoyInstrumentStatusBackend(InstrumentStatusBackend):                                                                                                                                             
    async def read_status(self):                                                                                                                                                                          
        s = await self._driver.get_status()        # firmware 0x0300                                                                                                                                      
        return InstrumentStatusReading(state=..., ...)                                                                                                                                                    
                                                                                                                                                                                                          
class ByonoyStatusIndicatorBackend(StatusIndicatorBackend):
    async def set_state(self, state, progress=None):                                                                                                                                                      
        # maps state → driver.set_led_colours / set_led_effect
        ...                                                                                                                                 

Byonoy implements both: InstrumentStatus wraps firmware report 0x0300;
StatusIndicator maps states to LED colours/effects. Low-level
pixel/effect stays on the driver as escape hatch.

Leaning (B). what do you think?

Comment on lines +47 to +51
```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
```
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think focal_height is something that byonoy supports?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes fixed it

…version

- 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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants