Byonoy L96: device queries, integration modes, cancel, LED bar#1027
Open
vcjdeboer wants to merge 28 commits into
Open
Byonoy L96: device queries, integration modes, cancel, LED bar#1027vcjdeboer wants to merge 28 commits into
vcjdeboer wants to merge 28 commits into
Conversation
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>
rickwierenga
reviewed
May 6, 2026
Member
rickwierenga
left a comment
There was a problem hiding this comment.
this is a great improvement!
…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>
…(colors, effect)
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ed_colors 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 255b8d6, 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 <noreply@anthropic.com>
Matches the v1b1 <Vendor><Device>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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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[<TheAlias>, ...] 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 <noreply@anthropic.com>
… concurrent read
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 <noreply@anthropic.com>
- rename device-query methods get_* → request_* (matches d364172 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Extends
pylabrobot/byonoyto drive the full HID surface the Luminescence 96 firmware advertises — beyond the existingread_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, fwLuminescence 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 viaREP_*_INreports (0x0080, 0x0050, 0x0010, 0x0300, 0x0310) andREP_DEVICE_DATA_READ_IN(0x0200) with the documentedrouting_info=\x80\x40request tag.read_data_field(field_index)— generic typed read of anyDD_*field.cancel(report_id=0x0340)— firesREP_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 viaREP_LED_BAR_COLOURS_OUT/REP_LED_BAR_EFFECTS_OUT.describe_error_code(code)— overridable per-backend decoder.LuminescenceParamsnow 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/Abs1StatusErrorenums mirroringhid-reports/.../abs96status.cppandabs1status.cpp.ByonoyAbsorbance96Backendopts 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.get_supported_reports()returns 23 IDs matching the firmware's published feature set.selected_wellsmask: column-1-only read returns column 0 with real data and columns 1-11 as exactly0.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 tois_measuring=Falseimmediately after.set_led_coloursandset_led_effectproduce the expected visual changes on the front bar.get_device_info()returns the same identity fields the C library'sbyonoy_get_device_informationreturns.Out of scope
The pre-existing collapse of
DriverandCapabilityBackendinto one class (ByonoyBase) is documented inARCHITECTURE_NOTES.mdbut not addressed here — that refactor is independent and benefits from being a single focused PR. The notes proposeByonoyDriver+ByonoyDiagnostics/ByonoyLEDBarhelpers (P-16 STARCover pattern), with concrete shape suggestions and v1b1 precedent paths.🤖 Generated with Claude Code