Byonoy L96: device queries, integration modes, cancel, LED bar#1027
Byonoy L96: device queries, integration modes, cancel, LED bar#1027vcjdeboer wants to merge 14 commits intoPyLabRobot:v1b1from
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
left a comment
There was a problem hiding this comment.
this is a great improvement!
There was a problem hiding this comment.
could you make this into a notebook so people can run it directly?
There was a problem hiding this comment.
Done, converted to lab_guide.ipynb
There was a problem hiding this comment.
could LED be its own capability / backend?
There was a problem hiding this comment.
(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?
| ```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 | ||
| ``` |
There was a problem hiding this comment.
I don't think focal_height is something that byonoy supports?
…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>
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