Files
2026-10-05 09:53:24 +00:00

62 KiB
Raw Permalink Blame History

Byonoy Device Library — Python SDK

Read the SDK guide first: it explains the two variants, how the library behaves, and the error codes. This document is the Python specifics.

Everything except section 5 is the public variant. Section 5 covers what the internal variant adds.


1. Install

The public package is on the public registry and needs no credentials. It is published as wheels only, for these platforms (release 2026.9.1):

OS Architecture Python
Linux x86_64 3.10 – 3.14
Linux aarch64 3.12, 3.14
Windows x86-64 3.10 – 3.14
macOS arm64, x86_64 3.13, 3.14

Always install into a virtual environment. Many managed interpreters (Homebrew, Debian) refuse to install into themselves (error: externally-managed-environment, PEP 668); others quietly fall back to a --user install, which is just as easy to lose track of. Use python -m pip from the environment, not a bare pip, which is frequently absent.

Linux and macOS

On Linux, install two system packages first — venv support, which Debian and Ubuntu ship separately, and the hidapi library the wheel links against:

sudo apt update && sudo apt install -y python3-venv libhidapi-libusb0     # Debian, Ubuntu
sudo dnf install hidapi                             # Fedora

Then, on Linux and macOS alike:

python3 -m venv .venv
./.venv/bin/python -m pip install \
  --index-url https://git.byonoy.com/api/packages/public/pypi/simple/ \
  byonoy_devices

The same wheels are attached to each release on the releases page, for machines without access to the registry: download the one matching your platform and Python version, and install the file directly (./.venv/bin/python -m pip install ./byonoy_devices-…whl).

On macOS, python3 must be 3.13 or 3.14; if it is older, name a supported interpreter explicitly (python3.13 -m venv .venv). Over a non-interactive ssh session Homebrew's /opt/homebrew/bin is usually not on PATH; add it, or call the interpreter by its full path. And the device only opens while a user is logged in to a desktop session on the Mac — see macOS and Windows in the SDK guide.

On Linux, the device also needs permissions before you can open it — see Linux permissions in the SDK guide, which uses the 70-byonoy.rules file shipped next to it.

If Linux setup went wrong, you will see one of these:

Message Cause and fix
The virtual environment was not created successfully because ensurepip is not available python3-venv is missing. Install it, delete the half-created .venv, and create it again — the broken one has no pip in it
ImportError: libhidapi-libusb.so.0: cannot open shared object file hidapi is missing. Install libhidapi-libusb0 (Debian, Ubuntu) or hidapi (Fedora)
Unable to locate package or has no installation candidate the package lists are empty, as on fresh cloud and container images. Run sudo apt update first

Windows (PowerShell)

On Windows, python3 is usually only the Microsoft Store placeholder (it prints Python was not found and exits with 9009). Use the py launcher or python:

py -0p                          # lists installed interpreters; pick a supported one
py -3.11 -m venv .venv          # or: python -m venv .venv
.\.venv\Scripts\python.exe --version
.\.venv\Scripts\python.exe -m pip install --disable-pip-version-check --index-url https://git.byonoy.com/api/packages/public/pypi/simple/ byonoy_devices

(--disable-pip-version-check only silences pip's update check, which PowerShell would otherwise show as a red error.) Check the version the environment actually got: some py launchers fall back to the default interpreter without a warning when the one you name is not installed.

No driver or permission step is needed. The rest of this document writes commands for bash; in PowerShell:

bash PowerShell
./.venv/bin/python .\.venv\Scripts\python.exe
NAME=value command $env:NAME='value'; command (stays set for the session; Remove-Item Env:NAME clears it)
python -c '…' with quotes inside a script file — quoting rarely survives PowerShell, least of all over ssh
a trailing \ to continue a line one line, or a trailing backtick `
> out.csv --output out.csv where the program offers it — PowerShell 5.1 re-encodes redirected output as UTF-16
timeout --foreground 300 command with_timeout.py — see Long calls

The longer programs in this document are plain Python files, so they run the same way on every OS. Windows PowerShell 5.1 shows anything a program writes to stderr — pip's warnings, status messages — as a red error record even when the program succeeded; check $LASTEXITCODE instead.

On Windows a virtual environment's python.exe is a small launcher that starts the base interpreter as a child, so Get-Process python shows two entries for every running program. That is not a second program holding the device; ending the launcher ends the child too.

If there is no wheel for you

There is no source distribution — wheels are built per OS, architecture and Python version, so not every combination exists. An unsupported interpreter gives:

ERROR: Could not find a version that satisfies the requirement byonoy_devices (from versions: none)
ERROR: No matching distribution found for byonoy_devices

That means the interpreter/platform pair has no wheel, not that anything is wrong with your setup. Check what exists before debugging (bash):

PYTAG=cp$(python3 -c 'import sys; print(f"{sys.version_info.major}{sys.version_info.minor}")')
case "$(uname -s)" in Darwin) PLAT=macosx ;; Linux) PLAT=linux ;; *) PLAT=win ;; esac
echo "looking for $PYTAG / $PLAT"

curl -fsS https://git.byonoy.com/api/packages/public/pypi/simple/byonoy-devices/ \
  | grep -oE '[a-z_0-9.+-]+\.whl' | sort -u | grep "$PYTAG" | grep "$PLAT"

In PowerShell:

$tag = "cp" + (python -c "import sys; print(f'{sys.version_info.major}{sys.version_info.minor}')")
(Invoke-WebRequest -UseBasicParsing https://git.byonoy.com/api/packages/public/pypi/simple/byonoy-devices/).Content |
  Select-String -AllMatches '[\w.+-]+\.whl' | ForEach-Object { $_.Matches.Value } |
  Sort-Object -Unique | Where-Object { $_ -match $tag -and $_ -match 'win' -and $_ -match '2026\.9\.1' }

Both filters matter — the ABI tag alone lists wheels for every platform. (The PowerShell version also filters on the release; without that it lists every older wheel too.) Switch to a supported interpreter or submit a request to Byonoy including OS, CPU architecture and Python version.


2. Smallest complete program

import byonoy_devices as byonoy

devices = byonoy.available_devices()
if not devices:
    raise SystemExit("no Byonoy device attached")

rc, handle = byonoy.open_device(devices[0])
if rc != byonoy.ErrorCode.NO_ERROR:
    raise SystemExit(f"open failed: {rc}")

try:
    rc, info = byonoy.get_device_information(handle)
    if rc != byonoy.ErrorCode.NO_ERROR:
        raise SystemExit(f"device info failed: {rc}")
    print(info.type.name, info.sn, info.ref_no, info.version)
finally:
    byonoy.free_device(handle)

Always close in a finally. An abandoned handle keeps a worker thread and the USB device claimed for the life of the process.

The info object carries exactly four fields:

Field Meaning
type device model, a DeviceTypes enum member
sn serial number
ref_no Byonoy reference/article number
version the device's firmware version, as a string whose format differs between devices (8, 41, Absorbance One V1.3.3)

3. Calling convention

Functions come in three shapes. Getting this wrong is the most common mistake:

Shape Example Returns
produces a value get_device_information, lum96_measure tuple (ErrorCode, value)
performs an action abs96_initialize_single_measurement ErrorCode alone
asks a question lum96_measurement_supported, device_open plain bool

So rc, info = byonoy.get_device_information(h) — never info = ....

A few plain accessors return a bare value and no code at all: available_devices, available_devices_count, library_version, enable_logging, and the free_* functions.

ErrorCode is not an int

ErrorCode is a pybind11 enum, not an IntEnum. Compare it directly (rc == byonoy.ErrorCode.NO_ERROR); to log or serialise a number use rc.value, because int(rc) raises TypeError.

Never write if rc == 0: or if rc != 0:. Neither raises. rc == 0 is always False and rc != 0 is always True, so a success test never fires and a failure test fires on every call — including the successful ones.


4. Taking a measurement

Ask first whether the attached device supports the modality:

if not byonoy.lum96_measurement_supported(handle):
    raise SystemExit("this device does not do 96-well luminescence")

cfg = byonoy.Lum96MeasurementConfig()
cfg.mode = byonoy.Lum96IntegrationMode.RAPID   # ~10 s; SENSITIVE is ~60 s
cfg.selected_wells = [True] * 96               # REQUIRED — see below

rc, values = byonoy.lum96_measure(handle, cfg)
if rc != byonoy.ErrorCode.NO_ERROR:
    raise SystemExit(f"measurement failed: {rc}")
print(len(values), "wells")

You must set selected_wells. A fresh config has all 96 entries False. On a healthy device, measuring with it succeeds — NO_ERROR, in a fraction of a second, with a full-length result of 96 zeros. Nothing reports a problem, and a plate of zeros is indistinguishable from a dark reading. The list must be exactly 96 entries; any other length raises TypeError. Set mode explicitly too rather than relying on the default.

To select part of a plate, index the list in the well order — row-major, so entry 0 is A1 and entry 12 is B1:

cfg.selected_wells = [i // 12 in (0, 1) for i in range(96)]   # rows A and B only

Lum96IntegrationMode has four members, in increasing integration time: RAPID, SENSITIVE, ULTRA_SENSITIVE, and CUSTOM. CUSTOM takes its duration from the config's custom_integration_time_ms field; the other three ignore it.

Mind the duration. The modes set the integration time per detector channel: RAPID 100 ms, SENSITIVE 2 s, ULTRA_SENSITIVE 20 s. A full plate takes roughly 30 × that — about 10 s, 60 s and 10 minutes. Never end an ULTRA_SENSITIVE read early: on a test unit, stopping one after 9 minutes left the device unable to measure until it was power-cycled. CUSTOM must be at least 50 ms (INVALID_ARGUMENT otherwise) and has no upper limit; its duration follows the same formula (see the SDK guide's timing notes).

Absorbance

Absorbance needs an initialise with the slot empty, then a measure with the plate (or cuvette) in. For an Absorbance 96 Automate:

if not byonoy.abs96_measurement_supported(handle):
    raise SystemExit("this device does not do 96-well absorbance")

# REQUIRED before a single-wavelength initialise, which otherwise fails with
# INVALID_ARGUMENT. It also tells you which wavelengths are valid.
rc, wavelengths = byonoy.abs96_get_available_wavelengths(handle)
if rc != byonoy.ErrorCode.NO_ERROR:
    raise SystemExit(f"wavelength query failed: {rc}")

cfg = byonoy.Abs96SingleMeasurementConfig()
cfg.sample_wavelength = wavelengths[0]
cfg.reference_wavelength = 0          # 0 = no reference wavelength
cfg.rapid_mode = False

rc = byonoy.abs96_initialize_single_measurement(handle, cfg)   # slot empty
if rc != byonoy.ErrorCode.NO_ERROR:
    raise SystemExit(f"initialise failed: {rc}")

# ... wait until get_device_slot_status reports OCCUPIED ...

rc, values = byonoy.abs96_single_measure(handle, cfg)          # plate in

Rules that are easy to miss:

  • Query the wavelengths first, on every handle. The library checks an initialise's wavelengths against what abs96_get_available_wavelengths returned on this handle, so a single-wavelength initialise on a fresh handle returns INVALID_ARGUMENT until you have queried. (The multiple-wavelength initialise currently skips that check; query anyway.)
  • Initialisation is per wavelength, and belongs to the handle. Every initialise records the wavelengths it calibrated — a single one its sample and reference wavelength — and a measure succeeds only if every wavelength it asks for is recorded; otherwise it returns NOT_INITIALIZED. After closing and reopening, or in a new process, nothing is recorded.
  • Single and multiple are the same operations. A multiple-wavelength initialise or measure is a sequence of single-wavelength ones, without a reference wavelength. You can therefore mix them — measure one wavelength after a multiple initialise, or several after single ones — as long as each wavelength was initialised. A reference wavelength is only applied by the single calls.
  • Wavelengths differ between units. Take them from abs96_get_available_wavelengths; do not hard-code one.
  • The library does not check for a plate. A measure on an empty slot returns NO_ERROR and meaningless near-zero values. Poll get_device_slot_status (EMPTY before initialising, OCCUPIED before measuring), as the complete example does.

The Absorbance One works the same way with absone_initialize_measurement and absone_measure, but it cannot sense its slot, and on a faulty device absone_measure can block indefinitely instead of failing. Check that the initialise took effect before measuring:

rc, initialized = byonoy.absone_is_initialized(handle)   # gated on absone_is_initialized_supported

Which modality does this device support?

Capability names do not follow mechanically from the measure-function names — notably the two abs96 measure functions share one predicate. Use this table rather than guessing:

Predicate Measure function(s)
lum96_measurement_supported lum96_measure
flu96_measurement_supported flu96_measure
abs96_measurement_supported abs96_single_measure, abs96_multiple_measure
absone_measurement_supported absone_measure

Decide the modality from these predicates only; helper predicates such as abs96_available_wavelengths_supported can answer True on devices that cannot measure that modality. To see every predicate your module exposes:

print(sorted(n for n in dir(byonoy) if n.endswith("_supported")))

Is my reading plausible?

Useful when verifying a setup, since you otherwise cannot tell a working install from a broken one. Observed on a Luminescence 96 with nothing inserted: values (in RLU) are whole numbers (as floats, e.g. -35.0) scattered around zero, and negative values are normal — the signal is background-corrected, so noise falls on both sides of zero. The spread depends on the mode:

Mode Standard deviation All values within
RAPID ~130 about ±400
SENSITIVE ~40 about ±200

Do not treat a wide RAPID scatter as a fault. A single well reading exactly 0 is possible.

Long calls

A measurement blocks for seconds to minutes and Ctrl-C will not interrupt it. Python also block-buffers stdout when it is redirected, so a script printing progress shows nothing until it finishes — use python -u (or flush=True) whenever you pipe or redirect.

A call can also block indefinitely (see the SDK guide's timing notes). To bound it, run the program as a child process that is ended when a limit passes. On Linux, timeout --foreground 300 … does that — plain timeout without --foreground detaches the program from the terminal, so its "press Enter" prompts never receive your input. macOS and Windows have no timeout. This works everywhere — save it as with_timeout.py:

"""Run a command and end it if it takes longer than a limit.

    python with_timeout.py 300 python -u measure_to_csv.py --output plate.csv
"""
import subprocess
import sys

limit = float(sys.argv[1])
try:
    sys.exit(subprocess.run(sys.argv[2:], timeout=limit).returncode)
except subprocess.TimeoutExpired:
    print(f"with_timeout: ended after {limit:.0f} s; the device may need a replug",
          file=sys.stderr)
    sys.exit(124)

Use the environment's interpreter for both (./.venv/bin/python with_timeout.py 300 ./.venv/bin/python -u …). Before the next attempt, make sure nothing is left holding the device: pgrep -fl python on Linux and macOS, Get-Process python on Windows. Ending a process in the middle of a measurement gives the library no chance to clean up: afterwards the device may refuse to measure or drop off USB until it is replugged. Treat the timeout as a way to keep your program alive, not as a routine control.

Useful entry points

byonoy.library_version()                # .major .minor .patch
byonoy.available_devices_count()
byonoy.enable_logging(True)             # verbose protocol logging to STDOUT
byonoy.get_device_status(handle)        # (ErrorCode, DeviceState): OK, ERROR, BROKEN_FW, UNKNOWN
byonoy.get_device_error(handle)         # (ErrorCode, int): last device error, 0 = none; call get_device_status first
byonoy.device_open(handle)              # bool, still connected?
byonoy.get_device_slot_status(handle)   # (ErrorCode, DeviceSlotState): EMPTY, OCCUPIED, UNDETERMINED, UNKNOWN
byonoy.get_device_temperature(handle)   # (ErrorCode, float) °C, gated on device_temperature_supported
byonoy.get_device_humidity(handle)      # (ErrorCode, float) relative humidity as a fraction (0.43 = 43 %)
byonoy.get_device_uptime(handle)        # (ErrorCode, int) seconds, gated on device_uptime_supported

See the SDK guide on device status and device error. UNDETERMINED means the device itself cannot tell whether a plate is present; a Luminescence 96 reports slot support but always answers UNDETERMINED. UNKNOWN means the slot query failed. Humidity is reported only by the luminescence and fluorescence devices; gate it on device_humidity_supported.

enable_logging(True) writes to stdout, unconditionally. If your program prints results to stdout, the protocol log interleaves with them.

library_version() reports the version compiled into the C library, which is not the version of the wheel you installed and may be older. Use importlib.metadata.version("byonoy_devices") to identify the package. Expect the same release to be spelled several ways:

Final release Pre-release
Tag v2026.09.1 v2026.09.1-beta1
pip install line 2026.9.1 2026.9.1b1
importlib.metadata v2026.09.1 v2026.09.1beta1

pip list shows either spelling, depending on the pip version.


5. The internal variant

Available on request; see the SDK guide for when you should want it. Everything above still applies — the module is a superset, and the only change to existing code is the import.

Install

Same command, different registry and package name, and it needs a token:

python3 -m venv .venv
./.venv/bin/python -m pip install \
  --index-url "https://${BYONOY_USER}:${BYONOY_TOKEN}@git.byonoy.com/api/packages/sw/pypi/simple/" \
  byonoy_devices_internal
import byonoy_devices_internal as byonoy   # the only source change

On the internal registry, a platform can at times be served only by pre-release versions — before 2026.9.1, macOS wheels existed only as betas. Tools disagree about that:

Tool Behaviour
pip falls back to a pre-release when no final version has a usable wheel — the command above just works
uv pip refuses, reporting only the platforms final releases cover. Add --prerelease=allow
uv venv --python 3.13 .venv        # name an interpreter that has a wheel
uv pip install --python .venv/bin/python --prerelease=allow \
  --index-url "https://${BYONOY_USER}:${BYONOY_TOKEN}@git.byonoy.com/api/packages/sw/pypi/simple/" \
  byonoy_devices_internal

Two uv traps. A uv venv environment has no pip inside it, so python -m pip fails there with No module named pip; stay on uv pip once you start with uv. And uv venv --python 3.13 may download an interpreter whose architecture is not your machine's — on an Apple Silicon Mac it can fetch an x86_64 CPython and then install x86_64 wheels, which run under Rosetta. The wheel is chosen by the interpreter's architecture, not the machine's.

What it adds

79 functions beyond the public module, in these areas:

Area Entry points
Asynchronous measurements lum96_measure_async, lum96_measure_completed, and the same pair for flu96, absone; abs96_single_measure_async / abs96_multiple_measure_async share abs96_measure_completed
Data fields data_fields_supported, enumerate_data_fields, get_known_data_field_infos, read_*_field_by_name / _by_id, write_*_field_*
Files files_supported, enumerate_files, get_known_file_infos, read_file
RPC rpc_supported, enumerate_rpcs, get_known_rpcs, execute_rpc_name, execute_rpc_id
Diagnostics get_status_report, get_esp_status_report, get_environment_report, get_versions, get_progress
LEDs led_effect_supported and the LED effect calls
Bootloader / flashing open_device_in_bootloader, flash_stm, flash_esp, lock_bootloader
Device control reboot

Each area has its own *_supported predicate — gate on it as you would for a measurement modality.

Asynchronous measurements

The reason most people ask for the internal variant: a blocking measurement holds your thread for up to a minute.

rc = byonoy.lum96_measure_async(handle, cfg)
while not byonoy.lum96_measure_completed(handle):
    time.sleep(0.5)
    # do other work here

Rebooting the device

The recovery described in the SDK guide, without unplugging anything:

byonoy.reboot(handle)
byonoy.free_device(handle)
time.sleep(5)
rc, handle = byonoy.open_device(byonoy.available_devices()[0])

get_versions(handle) (gated on versions_supported) gives the component firmware versions behind the single info.version string.


6. Complete example: measure and export CSV

A complete program that takes one realistic measurement on whatever device is attached and writes it as CSV. It is deliberately verbose: every return code is checked, the device is always released, and nothing is measured until the device, the plate and the configuration have all been checked. Use it as it stands, or as the template for your own program.

What it shows, per device:

Device Modality Flow
Absorbance 96 Automate abs96 pick wavelength(s) from those available → initialise with the slot empty → insert plate → measure; one wavelength uses the single-measurement call (optionally with a reference wavelength), several use the multiple-measurement call
Absorbance One absone the device's one wavelength → initialise with the slot empty → insert cuvette → check it initialised → measure; a single value
Luminescence 96 lum96 set mode (RAPID or SENSITIVE) and all wells explicitly → confirm plate → measure
Fluorescence 96 flu96 pick a filter set from those available → as luminescence

A combined device supports more than one modality; the program takes the first it finds and --modality picks the other. Whether a device is combined shows only once it is open, from its predicates — not from the discovery type, which reads AbsorbanceOneOr96 for every Absorbance One.

Before anything else it checks that the device status is OK and the device error is 0, and refuses to measure otherwise.

Plate handling depends on what the device can sense:

Device Slot state What the program does
Absorbance 96 Automate EMPTY / OCCUPIED polls the slot, with a timeout (--plate-timeout, default 120 s); --assume-ready has no effect
Absorbance One not supported asks the operator twice — slot empty before initialising, cuvette in before measuring
Luminescence 96 always UNDETERMINED (cannot tell) asks the operator once
Fluorescence 96 varies asks the operator once

Without a terminal the program cannot ask, and stops with an error unless given --assume-ready. On an Absorbance One that flag vouches for both states — an empty slot at initialise and a cuvette at measure — so use it only in a setup that guarantees both. That makes the program safe to run from a script or an agent: it measures a plate someone has vouched for, or it stops with an error.

Save it as measure_to_csv.py next to your .venv and run it with the environment's interpreter:

./.venv/bin/python -u measure_to_csv.py --output plate.csv           # whatever is attached
./.venv/bin/python -u measure_to_csv.py --modality abs96 --wavelength 492 --output plate.csv
./.venv/bin/python -u measure_to_csv.py --mode sensitive --assume-ready --output lum.csv
./.venv/bin/python -u measure_to_csv.py --modality flu96 --filter-set 2 --output flu.csv
.\.venv\Scripts\python.exe -u measure_to_csv.py --output plate.csv

Wavelengths differ between units: the program logs the available ones as it starts, and without --wavelength it measures all of them.

Prefer --output to redirecting stdout. The program writes the file only after the measurement has succeeded — a failed run leaves an existing file of that name untouched, so check the exit code rather than the file's presence. By contrast, > plate.csv creates an empty file even when the run fails — and Windows PowerShell 5.1 re-encodes redirected output as UTF-16, which most CSV readers reject.

#!/usr/bin/env python3
"""Take one measurement on an attached Byonoy device and write it as CSV.

The modality is chosen from what the device reports it supports, or forced
with --modality. Results go to stdout (or --output); every status message goes
to stderr, so the CSV stays clean when you redirect or pipe it.

    python -u measure_to_csv.py --output plate.csv
    python -u measure_to_csv.py --modality abs96 --wavelength 492 --output plate.csv
"""

import argparse
import csv
import datetime
import sys
import time

import byonoy_devices as byonoy  # internal variant: import byonoy_devices_internal as byonoy

CSV_COLUMNS = [
    "timestamp_utc",
    "serial_number",
    "ref_no",
    "firmware_version",
    "device_type",
    "modality",
    "mode",
    "wavelength_nm",
    "reference_wavelength_nm",
    "filter_set_index",
    "excitation_nm",
    "emission_nm",
    "well_index",
    "well",
    "value",
    "unit",
]

# ULTRA_SENSITIVE (about 10 minutes per full plate) and CUSTOM are left out on
# purpose: stopping a read early has left test devices unable to measure.
INTEGRATION_MODES = ["rapid", "sensitive"]


class MeasurementError(Exception):
    pass


def log(message):
    print(message, file=sys.stderr, flush=True)


def well_name(index, columns=12):
    """Results are row-major: index 0 is A1, 11 is A12, 12 is B1, 95 is H12."""
    return f"{chr(ord('A') + index // columns)}{index % columns + 1}"


def describe(rc):
    return f"{rc.name} (0x{rc.value:04x})"


def check(rc, what):
    """Raise unless rc is NO_ERROR. Never compare rc against 0."""
    if rc != byonoy.ErrorCode.NO_ERROR:
        hint = ""
        if rc == byonoy.ErrorCode.DEVICE_COMMUNICATION_FAILURE:
            hint = " (is another program still holding the device open? on Linux: permissions?)"
        elif rc == byonoy.ErrorCode.DEVICE_OPERATION_FAILED:
            hint = " (if every measurement fails like this: 'When the device stops measuring')"
        raise MeasurementError(f"{what} failed: {describe(rc)}{hint}")


# --- device discovery -------------------------------------------------------


def open_device(serial_number):
    devices = byonoy.available_devices()
    if not devices:
        raise MeasurementError(
            "no Byonoy device found (on Linux, check the udev rules in the SDK guide)"
        )
    for d in devices:
        log(f"found {d.type.name} sn={d.sn}")
        if not d.sn:
            log("  (no serial number: on Linux, missing permissions — or, after an aborted "
                "run, a device that needs replugging)")

    if serial_number is not None:
        devices = [d for d in devices if d.sn == serial_number]
        if not devices:
            raise MeasurementError(f"no device with serial number {serial_number}")
    elif len(devices) > 1:
        raise MeasurementError("more than one device attached; pick one with --serial")

    rc, handle = byonoy.open_device(devices[0])
    check(rc, "open_device")
    return handle


def preflight(handle):
    rc, info = byonoy.get_device_information(handle)
    check(rc, "get_device_information")
    log(f"opened {info.type.name} sn={info.sn} ref={info.ref_no} fw={info.version}")

    rc, state = byonoy.get_device_status(handle)
    check(rc, "get_device_status")
    rc, device_error = byonoy.get_device_error(handle)
    check(rc, "get_device_error")
    if state != byonoy.DeviceState.OK or device_error:
        raise MeasurementError(
            f"device status is {state.name}, device error {device_error} ({device_error:#x}); not measuring "
            "(report both, with the serial number and firmware, to Byonoy support)"
        )
    return info


def detect_modality(handle):
    # Order matters only for combined devices (AbsorbanceOneOr96); use
    # --modality to pick the other one.
    candidates = [
        ("abs96", byonoy.abs96_measurement_supported),
        ("absone", byonoy.absone_measurement_supported),
        ("lum96", byonoy.lum96_measurement_supported),
        ("flu96", byonoy.flu96_measurement_supported),
    ]
    supported = [name for name, predicate in candidates if predicate(handle)]
    if not supported:
        raise MeasurementError("device supports none of the known measurement modalities")
    log(f"supported modalities: {', '.join(supported)}")
    return supported


# --- plate handling ---------------------------------------------------------


def slot_state(handle):
    """Current slot state, or None if the device cannot report one."""
    if not byonoy.device_slot_status_supported(handle):
        return None
    rc, state = byonoy.get_device_slot_status(handle)
    check(rc, "get_device_slot_status")
    return state


def wait_for_slot(handle, wanted, instruction, args):
    """Wait until the slot reports `wanted`, or until the operator confirms."""
    state = slot_state(handle)
    if state is None:
        confirm(instruction, args)
        return
    if state == wanted:
        return

    log(f"{instruction} (waiting up to {args.plate_timeout:.0f} s for the slot to read {wanted.name})")
    deadline = time.monotonic() + args.plate_timeout
    while time.monotonic() < deadline:
        time.sleep(0.5)
        if slot_state(handle) == wanted:
            log(f"slot is {wanted.name}")
            time.sleep(1.0)  # let the plate settle before anything moves
            return
    raise MeasurementError(f"timed out waiting for the slot to read {wanted.name}")


def confirm(instruction, args):
    """For devices that cannot sense the plate: a human has to say it is ready."""
    if args.assume_ready:
        log(f"{instruction} (skipped: --assume-ready)")
        return
    if not sys.stdin.isatty():
        raise MeasurementError(
            f"cannot confirm '{instruction}' without a terminal; "
            "run interactively, or pass --assume-ready once it is done"
        )
    print(f"{instruction}, then press Enter... ", end="", file=sys.stderr, flush=True)
    sys.stdin.readline()


def check_alignment(handle):
    if not byonoy.device_parts_aligned_supported(handle):
        return
    rc, aligned = byonoy.get_device_parts_aligned(handle)
    check(rc, "get_device_parts_aligned")
    if not aligned:
        raise MeasurementError("device parts are not aligned; seat the device correctly and retry")


# --- one function per modality ----------------------------------------------
# Each returns a list of partial CSV rows; main() adds the device columns.


def measure_abs96(handle, args):
    if not byonoy.abs96_available_wavelengths_supported(handle):
        raise MeasurementError("device cannot report its absorbance wavelengths")
    # Querying the wavelengths is also REQUIRED before a single-wavelength
    # initialise: without it, the initialise returns INVALID_ARGUMENT.
    rc, available = byonoy.abs96_get_available_wavelengths(handle)
    check(rc, "abs96_get_available_wavelengths")
    log(f"available wavelengths: {available}")

    wavelengths = args.wavelength or available
    unknown = [w for w in wavelengths if w not in available]
    if unknown:
        raise MeasurementError(f"wavelength(s) {unknown} not available on this device")
    reference = args.reference_wavelength or 0
    if reference and reference not in available:
        raise MeasurementError(f"reference wavelength {reference} not available on this device")
    mode = "rapid" if args.rapid else "standard"

    # Initialise with the slot empty, then measure with the plate in.
    wait_for_slot(handle, byonoy.DeviceSlotState.EMPTY, "Remove any plate from the device", args)

    if len(wavelengths) == 1:
        cfg = byonoy.Abs96SingleMeasurementConfig()
        cfg.sample_wavelength = wavelengths[0]
        cfg.reference_wavelength = reference  # 0 = no reference wavelength
        cfg.rapid_mode = args.rapid
        log("initialising (slot must be empty)...")
        check(byonoy.abs96_initialize_single_measurement(handle, cfg), "abs96_initialize_single_measurement")
        wait_for_slot(handle, byonoy.DeviceSlotState.OCCUPIED, "Insert the plate", args)
        check_alignment(handle)
        log(f"measuring at {wavelengths[0]} nm...")
        rc, values = byonoy.abs96_single_measure(handle, cfg)
        check(rc, "abs96_single_measure")
        per_wavelength = [values]
    else:
        if reference:
            raise MeasurementError("a reference wavelength needs a single --wavelength")
        cfg = byonoy.Abs96MultipleMeasurementConfig()
        cfg.sample_wavelengths = list(wavelengths)
        cfg.rapid_mode = args.rapid
        log("initialising (slot must be empty)...")
        check(byonoy.abs96_initialize_multiple_measurement(handle, cfg), "abs96_initialize_multiple_measurement")
        wait_for_slot(handle, byonoy.DeviceSlotState.OCCUPIED, "Insert the plate", args)
        check_alignment(handle)
        log(f"measuring at {', '.join(map(str, wavelengths))} nm...")
        rc, per_wavelength = byonoy.abs96_multiple_measure(handle, cfg)
        check(rc, "abs96_multiple_measure")
        if len(per_wavelength) != len(wavelengths):
            raise MeasurementError(
                f"expected {len(wavelengths)} result sets, got {len(per_wavelength)}"
            )

    rows = []
    for wavelength, values in zip(wavelengths, per_wavelength):
        for well, value in enumerate(values):
            rows.append({
                "modality": "abs96",
                "mode": mode,
                "wavelength_nm": wavelength,
                "reference_wavelength_nm": reference or "",
                "well_index": well,
                "well": well_name(well),
                "value": value,
                "unit": "OD",
            })
    return rows


def measure_absone(handle, args):
    if not byonoy.absone_available_wavelength_supported(handle):
        raise MeasurementError("device cannot report its absorbance wavelength")
    rc, wavelength = byonoy.absone_get_available_wavelength(handle)
    check(rc, "absone_get_available_wavelength")
    if args.wavelength and args.wavelength != [wavelength]:
        raise MeasurementError(f"this device measures at {wavelength} nm only")

    wait_for_slot(handle, byonoy.DeviceSlotState.EMPTY, "Remove any cuvette from the device", args)
    log("initialising (slot must be empty)...")
    check(byonoy.absone_initialize_measurement(handle, wavelength), "absone_initialize_measurement")
    # absone_measure can block indefinitely instead of failing, so confirm the
    # initialise took effect before calling it.
    if byonoy.absone_is_initialized_supported(handle):
        rc, initialized = byonoy.absone_is_initialized(handle)
        check(rc, "absone_is_initialized")
        if not initialized:
            raise MeasurementError("initialise reported success but the device is not initialised")
    wait_for_slot(handle, byonoy.DeviceSlotState.OCCUPIED, "Insert the cuvette", args)
    check_alignment(handle)

    log(f"measuring at {wavelength} nm...")
    rc, value = byonoy.absone_measure(handle, wavelength)
    check(rc, "absone_measure")
    # A single cuvette: one row, no well index.
    return [{"modality": "absone", "wavelength_nm": wavelength, "well_index": "", "well": "",
             "value": value, "unit": "OD"}]


def luminescence_like(handle, args, modality, config_class, mode_enum, measure, wells, extra=None):
    """Shared by lum96 and flu96: pick a mode, select every well, measure."""
    cfg = config_class()
    cfg.mode = getattr(mode_enum, args.mode.upper())  # always set explicitly
    cfg.selected_wells = [True] * wells  # REQUIRED: a fresh config selects nothing
    if extra:
        extra(cfg)

    state = slot_state(handle)
    if state is not None:
        log(f"slot reports {state.name}")
    confirm("Make sure the plate is in place", args)
    check_alignment(handle)

    integration = {"rapid": "100 ms", "sensitive": "2 s"}[args.mode]
    if modality == "lum96":
        timing = {"rapid": "about 10 s", "sensitive": "about 60 s"}[args.mode] + " for all wells"
    else:
        timing = f"{integration} integration; time grows with the number of columns used"
    log(f"measuring {wells} wells, mode {args.mode} ({timing}; Ctrl-C will not interrupt it)...")
    started = time.monotonic()
    rc, values = measure(handle, cfg)
    check(rc, f"{modality}_measure")
    log(f"done in {time.monotonic() - started:.1f} s")

    if len(values) != wells:
        raise MeasurementError(f"expected {wells} values, got {len(values)}")
    return [
        {
            "modality": modality,
            "mode": args.mode,
            "well_index": well,
            "well": well_name(well),
            "value": value,
            "unit": "RLU" if modality == "lum96" else "RFU",
        }
        for well, value in enumerate(values)
    ]


def measure_lum96(handle, args):
    return luminescence_like(handle, args, "lum96", byonoy.Lum96MeasurementConfig,
                             byonoy.Lum96IntegrationMode, byonoy.lum96_measure, 96)


def measure_flu96(handle, args):
    if not byonoy.flu96_available_filter_sets_supported(handle):
        raise MeasurementError("device cannot report its filter sets")
    rc, filter_sets = byonoy.flu96_get_available_filter_sets(handle)
    check(rc, "flu96_get_available_filter_sets")
    if not filter_sets:
        raise MeasurementError("device reports no filter sets")
    for fs in filter_sets:
        log(f"filter set {fs.index}: excitation {fs.excitation_wavelength_lower_bound}-"
            f"{fs.excitation_wavelength_upper_bound} nm, emission "
            f"{fs.measurement_wavelength_lower_bound}-{fs.measurement_wavelength_upper_bound} nm")

    if args.filter_set is None:
        chosen = filter_sets[0]
    else:
        matches = [fs for fs in filter_sets if fs.index == args.filter_set]
        if not matches:
            raise MeasurementError(f"filter set {args.filter_set} not available on this device")
        chosen = matches[0]

    def set_filter(cfg):
        cfg.filter_set_index = chosen.index

    rows = luminescence_like(handle, args, "flu96", byonoy.Flu96MeasurementConfig,
                             byonoy.Flu96IntegrationMode, byonoy.flu96_measure, 96, set_filter)
    for row in rows:
        row["filter_set_index"] = chosen.index
        row["excitation_nm"] = (f"{chosen.excitation_wavelength_lower_bound}-"
                                f"{chosen.excitation_wavelength_upper_bound}")
        row["emission_nm"] = (f"{chosen.measurement_wavelength_lower_bound}-"
                              f"{chosen.measurement_wavelength_upper_bound}")
    return rows


MEASURE = {
    "abs96": measure_abs96,
    "absone": measure_absone,
    "lum96": measure_lum96,
    "flu96": measure_flu96,
}


# --- output -----------------------------------------------------------------


def write_csv(rows, path):
    # Rows are only written once the measurement has fully succeeded, so a
    # failed run never leaves a half-written file behind.
    out = open(path, "w", newline="", encoding="utf-8") if path else sys.stdout
    try:
        writer = csv.DictWriter(out, fieldnames=CSV_COLUMNS, restval="", lineterminator="\n")
        writer.writeheader()
        writer.writerows(rows)
    finally:
        if path:
            out.close()


def parse_args():
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--modality", choices=sorted(MEASURE), help="default: what the device supports")
    p.add_argument("--serial", help="serial number of the device to use when several are attached")
    p.add_argument("--output", help="CSV file to write (default: stdout)")
    p.add_argument("--mode", choices=INTEGRATION_MODES, default="rapid",
                   help="luminescence/fluorescence integration mode (default: rapid); "
                        "ULTRA_SENSITIVE (~10 min) and CUSTOM are deliberately not offered")
    p.add_argument("--wavelength", type=int, action="append",
                   help="absorbance wavelength in nm; repeat for several (default: all available)")
    p.add_argument("--reference-wavelength", type=int, help="abs96 reference wavelength in nm")
    p.add_argument("--rapid", action="store_true", help="abs96 rapid mode")
    p.add_argument("--filter-set", type=int, help="flu96 filter set index (default: the first)")
    p.add_argument("--plate-timeout", type=float, default=120.0,
                   help="seconds to wait for a plate to be inserted or removed (default: 120)")
    p.add_argument("--assume-ready", action="store_true",
                   help="do not ask for confirmation on devices that cannot sense the plate")
    return p.parse_args()


def main():
    args = parse_args()
    handle = None
    try:
        handle = open_device(args.serial)
        info = preflight(handle)

        supported = detect_modality(handle)
        modality = args.modality or supported[0]
        if modality not in supported:
            raise MeasurementError(f"this device does not support {modality}")

        rows = MEASURE[modality](handle, args)

        timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
        for row in rows:
            row.update({
                "timestamp_utc": timestamp,
                "serial_number": info.sn,
                "ref_no": info.ref_no,
                "firmware_version": info.version,
                "device_type": info.type.name,
            })
        write_csv(rows, args.output)
        log(f"wrote {len(rows)} rows" + (f" to {args.output}" if args.output else ""))
        return 0
    except MeasurementError as e:
        log(f"error: {e}")
        return 1
    finally:
        if handle is not None:
            byonoy.free_device(handle)  # always release the device


if __name__ == "__main__":
    sys.exit(main())

The CSV

One row per value, in long format: the same sixteen columns for every device, so files from different instruments can be concatenated and filtered without reshaping. Columns that do not apply to a modality are left empty.

Column Contents
timestamp_utc when the measurement finished, ISO 8601
serial_number, ref_no, firmware_version, device_type from the device information call — enough to trace every row back to an instrument
modality abs96, absone, lum96 or flu96
mode rapid/sensitive for luminescence and fluorescence; rapid/standard for abs96; empty for absone
wavelength_nm, reference_wavelength_nm absorbance only
filter_set_index, excitation_nm, emission_nm fluorescence only; the filter bands as lower-upper
well_index 0-based index into the result, as returned by the library; empty for absone
well the plate position (A1 … H12); empty for absone
value the reading, exactly as returned
unit OD for absorbance, RLU for luminescence, RFU for fluorescence
timestamp_utc,serial_number,ref_no,firmware_version,device_type,modality,mode,wavelength_nm,reference_wavelength_nm,filter_set_index,excitation_nm,emission_nm,well_index,well,value,unit
2026-09-30T16:31:43+00:00,SN1,REF1,8,Absorbance96,abs96,standard,492,,,,,0,A1,0.145,OD
2026-09-30T16:31:44+00:00,SN1,REF1,41,Luminescence96,lum96,rapid,,,,,,13,B2,-35.0,RLU

Well names follow the row-major order described in the SDK guide; units are explained under Units.

A multi-wavelength abs96 run gives 96 rows per wavelength. With --output, rows are written only after the measurement has succeeded; a failed run writes nothing and leaves any existing file as it was.


7. Prototyping without a device

The SDK has no simulator, but for prototyping — building a UI, a pipeline or a test suite before hardware is on your desk — it is enough to have the API answer with plausible values. Save this as byonoy_mock.py next to your code. It patches the installed module in place, so the code under development stays exactly as it will run against a real device:

"""A simulated Byonoy device, for prototyping without hardware.

Import it once, before anything else touches the SDK:

    import byonoy_mock                 # patches byonoy_devices in place
    import byonoy_devices as byonoy    # your code, unchanged

It needs the real wheel installed. Enums, config classes and their argument
checks stay the real ones; only the calls that would talk to a device are
replaced. Pick the simulated device with an environment variable:

    BYONOY_MOCK_DEVICE=Absorbance96|AbsorbanceOne|Luminescence96|Fluorescence96

Values are plausible, not realistic: they have the right shape and rough
magnitude, nothing more. Measurements return instantly.
"""

import math
import os
import random

import byonoy_devices as byonoy  # internal variant: import byonoy_devices_internal as byonoy

OK = byonoy.ErrorCode.NO_ERROR
DEVICES = ["Absorbance96", "AbsorbanceOne", "Luminescence96", "Fluorescence96"]
_name = os.environ.get("BYONOY_MOCK_DEVICE", "Luminescence96")
if _name not in DEVICES:
    raise SystemExit(f"BYONOY_MOCK_DEVICE={_name!r} is not simulated; use one of: {', '.join(DEVICES)}")
DEVICE = byonoy.DeviceTypes[_name]
# Discovery reports the type from the USB IDs alone; every Absorbance One is
# listed as AbsorbanceOneOr96 there, as with the real library.
DISCOVERED = byonoy.DeviceTypes.AbsorbanceOneOr96 if _name == "AbsorbanceOne" else DEVICE
SERIAL = "MOCK-0001"
PRODUCT_IDS = {"Absorbance96": 0x1199, "AbsorbanceOne": 0x106A, "Luminescence96": 0x119B,
               "Fluorescence96": 0x12F2}

_open = set()     # handles currently open
_state = {"initialized": False, "plate": False, "wavelengths_queried": False}


def _device():
    d = byonoy.Device()
    d.type, d.sn, d.vid, d.pid = DISCOVERED, SERIAL, 0x16D0, PRODUCT_IDS.get(DEVICE.name, 0)
    return d


def _is(*types):
    return lambda handle: DEVICE.name in types


def _call(fn, supported=lambda handle: True, action=False):
    """Wrap a mocked call: reject unknown handles and unsupported operations.

    Calls that produce a value return (ErrorCode, value); actions
    (action=True) return the ErrorCode alone, as in the real module.
    """
    def wrapper(handle, *args):
        if handle not in _open:
            rc = byonoy.ErrorCode.INVALID_ARGUMENT
        elif not supported(handle):
            rc = byonoy.ErrorCode.UNSUPPORTED_OPERATION
        else:
            return fn(handle, *args)
        return rc if action else (rc, None)
    return wrapper


# --- discovery and lifecycle ------------------------------------------------

def open_device(device):
    if _open:
        return byonoy.ErrorCode.DEVICE_ALREADY_OPEN, 0
    _open.add(1)
    return OK, 1


def free_device(handle):
    _open.discard(handle)
    _state.update(initialized=False, plate=False, wavelengths_queried=False)


def get_device_information(handle):
    info = byonoy.DeviceInfo()
    info.type, info.sn, info.ref_no, info.version = DEVICE, SERIAL, "MOCK-REF", "0.0.0-mock"
    return OK, info


# --- plate handling ---------------------------------------------------------
# Absorbance 96: the slot reads EMPTY until the measurement is initialised,
# then OCCUPIED, as if the operator had inserted the plate on cue. The
# Absorbance One, like the real one, cannot sense its slot.

def _slot(handle):
    return OK, byonoy.DeviceSlotState.OCCUPIED if _state["plate"] else byonoy.DeviceSlotState.EMPTY


def _initialize(handle, *_):
    _state.update(initialized=True, plate=True)
    return OK


def _initialize_single(handle, cfg):
    # Like the real library: fails until the wavelengths have been queried.
    if not _state["wavelengths_queried"]:
        return byonoy.ErrorCode.INVALID_ARGUMENT
    return _initialize(handle)


def _wavelengths(handle):
    _state["wavelengths_queried"] = True
    return OK, [405, 450, 492, 620] if DEVICE.name == "Absorbance96" else [450]


# --- measurements -----------------------------------------------------------

def _od(well):
    """A dilution series: absorbance rises across the columns, A1 lowest."""
    return round(0.045 + 0.1 * (well % 12) + random.gauss(0, 0.005), 4)


def _abs96_single(handle, cfg):
    if not _state["initialized"]:
        return byonoy.ErrorCode.NOT_INITIALIZED, [0.0] * 96
    return OK, [_od(i) for i in range(96)]


def _abs96_multiple(handle, cfg):
    if not _state["initialized"]:
        return byonoy.ErrorCode.NOT_INITIALIZED, []
    return OK, [[_od(i) for i in range(96)] for _ in cfg.sample_wavelengths]


def _absone(handle, wavelength):
    if not _state["initialized"]:
        return byonoy.ErrorCode.NOT_INITIALIZED, math.nan
    return OK, round(0.2 + random.gauss(0, 0.005), 4)


def _luminescence(handle, cfg):
    # Background-corrected whole numbers around zero; shorter integration is noisier.
    sigma = {"RAPID": 130.0, "SENSITIVE": 40.0}.get(cfg.mode.name, 40.0)
    return OK, [float(round(random.gauss(0, sigma))) if s else 0.0 for s in cfg.selected_wells]


def _fluorescence(handle, cfg):
    return OK, [round(150 + random.gauss(0, 15), 1) if s else math.nan for s in cfg.selected_wells]


def _filter_sets(handle):
    bands = [(470, 490, 510, 540), (530, 550, 580, 620)]
    sets = []
    for index, (ex_lo, ex_hi, em_lo, em_hi) in enumerate(bands):
        fs = byonoy.Flu96FilterSet()
        fs.index = index
        fs.excitation_wavelength_lower_bound, fs.excitation_wavelength_upper_bound = ex_lo, ex_hi
        fs.measurement_wavelength_lower_bound, fs.measurement_wavelength_upper_bound = em_lo, em_hi
        sets.append(fs)
    return OK, sets


abs96 = _is("Absorbance96")
absone = _is("AbsorbanceOne")
# As on the real devices, each absorbance device also answers True to the other
# one's wavelength predicate; only *_measurement_supported decides the modality.
either_absorbance = _is("Absorbance96", "AbsorbanceOne")

_MOCKS = {
    "available_devices": lambda: [_device()],
    "available_devices_count": lambda: 1,
    "open_device": open_device,
    "free_device": free_device,
    "device_open": lambda handle: handle in _open,
    "get_device_information": _call(get_device_information),
    "get_device_status": _call(lambda h: (OK, byonoy.DeviceState.OK)),
    "get_device_error": _call(lambda h: (OK, 0)),
    "device_temperature_supported": lambda h: DEVICE.name != "AbsorbanceOne",
    "get_device_temperature": _call(lambda h: (OK, round(24.0 + random.gauss(0, 0.2), 2))),
    "device_humidity_supported": lambda h: False,
    "device_uptime_supported": lambda h: True,
    "get_device_uptime": _call(lambda h: (OK, 3600)),
    "device_slot_status_supported": abs96,
    "get_device_slot_status": _call(_slot, abs96),
    "device_parts_aligned_supported": abs96,
    "get_device_parts_aligned": _call(lambda h: (OK, True), abs96),
    "device_readout_orientation_supported": lambda h: False,
    "device_update_supported": lambda h: False,

    "abs96_measurement_supported": abs96,
    "abs96_available_wavelengths_supported": either_absorbance,
    "abs96_modules_supported": lambda h: False,
    "abs96_get_available_wavelengths": _call(_wavelengths, either_absorbance),
    "abs96_initialize_single_measurement": _call(_initialize_single, abs96, action=True),
    "abs96_initialize_multiple_measurement": _call(_initialize, abs96, action=True),
    "abs96_single_measure": _call(_abs96_single, abs96),
    "abs96_multiple_measure": _call(_abs96_multiple, abs96),

    "absone_measurement_supported": absone,
    "absone_available_wavelength_supported": either_absorbance,
    "absone_get_available_wavelength": _call(lambda h: (OK, 450), absone),
    "absone_is_initialized_supported": absone,
    "absone_is_initialized": _call(lambda h: (OK, _state["initialized"]), absone),
    "absone_initialize_measurement": _call(_initialize, absone, action=True),
    "absone_measure": _call(_absone, absone),

    "lum96_measurement_supported": _is("Luminescence96"),
    "lum96_measure": _call(_luminescence, _is("Luminescence96")),

    "flu96_measurement_supported": _is("Fluorescence96"),
    "flu96_available_filter_sets_supported": _is("Fluorescence96"),
    "flu96_get_available_filter_sets": _call(_filter_sets, _is("Fluorescence96")),
    "flu96_measure": _call(_fluorescence, _is("Fluorescence96")),
}

for _name, _fn in _MOCKS.items():
    setattr(byonoy, _name, _fn)

Use it by importing it first:

import byonoy_mock                 # remove this line to talk to real hardware
import byonoy_devices as byonoy

Or leave your program untouched and load the mock from outside it with this small runner, saved as run_with_mock.py:

"""Run a Python program against the simulated device instead of real hardware.

    python run_with_mock.py measure_to_csv.py --assume-ready --output plate.csv
"""
import runpy
import sys

import byonoy_mock  # noqa: F401  (patches byonoy_devices before the program imports it)

sys.argv = sys.argv[1:]
runpy.run_path(sys.argv[0], run_name="__main__")

Here it runs the complete example against a simulated Absorbance 96:

BYONOY_MOCK_DEVICE=Absorbance96 ./.venv/bin/python run_with_mock.py measure_to_csv.py --output mock-plate.csv
$env:BYONOY_MOCK_DEVICE='Absorbance96'; .\.venv\Scripts\python.exe run_with_mock.py measure_to_csv.py --output mock-plate.csv

Every simulated device except the Absorbance 96 needs --assume-ready (or a terminal) to get past the example's plate prompts, just as the real ones do. Keep simulated and real output in differently named files, so a stale mock file is never mistaken for a real result.

What the simulated device does:

  • one device, serial number MOCK-0001, of the type named by BYONOY_MOCK_DEVICE (default Luminescence96; an unknown name stops with the list of valid ones); its *_supported predicates answer for that type only, and calls for any other modality return UNSUPPORTED_OPERATION
  • the return shapes of the real module — (ErrorCode, value) tuples, bare ErrorCodes for actions, real DeviceInfo and Flu96FilterSet objects
  • DEVICE_ALREADY_OPEN on a second open, and INVALID_ARGUMENT for a handle that was freed
  • Absorbance 96: the slot reads EMPTY until the measurement is initialised and OCCUPIED after, as if the plate were inserted on cue. As on the real device, a single-wavelength initialise fails with INVALID_ARGUMENT until the wavelengths have been queried, and measuring before initialising gives NOT_INITIALIZED. Values rise across the columns (about 0.05 in column 1 to 1.15 in column 12), so a mix-up in well order is easy to spot
  • Absorbance One: like the real one it cannot sense its slot (so the example needs --assume-ready or a terminal), measures at 450 nm, and is listed by discovery as AbsorbanceOneOr96
  • as on the real devices, each absorbance device also answers True to the other one's wavelength predicate
  • luminescence: whole-number noise around zero, with the spread given under Is my reading plausible?, and 0 for deselected wells
  • fluorescence: two filter sets, values around 150, and NaN for deselected wells

What it does not do: take time, fail the way hardware fails, or produce values that mean anything. Measurements return instantly, so it will not show you a timeout that is too short, a UI that freezes during a 60-second read, or a call that never returns. Combined devices are not simulated, the Absorbance 96's module calls (abs96_modules_supported and friends) and firmware update answer as unsupported, and temperature is reported by every simulated device except the Absorbance One. Use it to get the plumbing right; validate against a real device.

The mock replaces only the public calls. With the internal variant, change the import at its top; calls it does not replace still go to the real library and will find no device.


8. Verify your setup

Confirms install and hardware together, and says plainly what it did and did not check. Expects a device attached. Save it as verify_setup.py (substitute byonoy_devices_internal in the import if that is the variant you installed):

"""Check the install and the attached device, and say plainly what was checked.

    python -u verify_setup.py
"""
import sys

import byonoy_devices as byonoy  # internal variant: import byonoy_devices_internal as byonoy

OK = byonoy.ErrorCode.NO_ERROR


def fail(message):
    sys.exit(f"FAILED: {message}")


def check(rc, what):
    if rc != OK:
        hint = ""
        if rc == byonoy.ErrorCode.DEVICE_COMMUNICATION_FAILURE:
            hint = " — another program holding the device? on Linux: permissions?"
        elif rc == byonoy.ErrorCode.DEVICE_OPERATION_FAILED:
            hint = " — if every measurement fails like this, see 'When the device stops measuring'"
        fail(f"{what} returned {rc.name} ({hex(rc.value)}){hint}")


v = byonoy.library_version()
print(f"library {v.major}.{v.minor}.{v.patch}")

devices = byonoy.available_devices()
print(f"{len(devices)} device(s)")
if not devices:
    fail("no device found — attach one (on Linux, also check permissions)")

rc, handle = byonoy.open_device(devices[0])
check(rc, "open_device")
try:
    rc, info = byonoy.get_device_information(handle)
    check(rc, "get_device_information")
    print(f"type {info.type.name} | sn {info.sn} | fw {info.version}")

    rc, state = byonoy.get_device_status(handle)
    check(rc, "get_device_status")
    rc, device_error = byonoy.get_device_error(handle)
    check(rc, "get_device_error")
    print(f"status {state.name} | device error {device_error} ({device_error:#x})")
    if state != byonoy.DeviceState.OK or device_error:
        fail("the device reports a problem; do not measure — report status and error to Byonoy support")

    # Measure too: open and status both succeed on a device that can no longer
    # measure. RAPID on all wells keeps this to about 10 s.
    if byonoy.lum96_measurement_supported(handle):
        cfg = byonoy.Lum96MeasurementConfig()
        cfg.mode = byonoy.Lum96IntegrationMode.RAPID
        cfg.selected_wells = [True] * 96
        rc, values = byonoy.lum96_measure(handle, cfg)
        check(rc, "lum96_measure")
        print(f"measured {len(values)} wells (luminescence 96)")
    elif byonoy.flu96_measurement_supported(handle):
        rc, filter_sets = byonoy.flu96_get_available_filter_sets(handle)
        check(rc, "flu96_get_available_filter_sets")
        if not filter_sets:
            fail("the device reports no filter sets")
        cfg = byonoy.Flu96MeasurementConfig()
        cfg.filter_set_index = filter_sets[0].index
        cfg.mode = byonoy.Flu96IntegrationMode.RAPID
        cfg.selected_wells = [True] * 96
        rc, values = byonoy.flu96_measure(handle, cfg)
        check(rc, "flu96_measure")
        print(f"measured {len(values)} wells (fluorescence 96)")
    elif byonoy.abs96_measurement_supported(handle):
        # Initialising needs an empty slot, the normal idle state, so it can be
        # checked safely; measuring needs a plate and is not checked here.
        rc, wavelengths = byonoy.abs96_get_available_wavelengths(handle)
        check(rc, "abs96_get_available_wavelengths")
        rc, slot = byonoy.get_device_slot_status(handle)
        check(rc, "get_device_slot_status")
        if slot != byonoy.DeviceSlotState.EMPTY:
            print(f"NOT MEASURED: slot is {slot.name}; remove the plate to check the initialise")
        else:
            cfg = byonoy.Abs96SingleMeasurementConfig()
            cfg.sample_wavelength = wavelengths[0]
            cfg.reference_wavelength = 0
            check(byonoy.abs96_initialize_single_measurement(handle, cfg),
                  "abs96_initialize_single_measurement")
            print(f"initialise at {wavelengths[0]} nm OK; NOT MEASURED (needs a plate)")
    else:
        print("NOT MEASURED: absorbance needs a cuvette in the slot, so this script "
              "checked communication and status only")
finally:
    byonoy.free_device(handle)
print("OK")

Run it under a time limit, since a measurement on a faulty device can block indefinitely:

./.venv/bin/python with_timeout.py 120 ./.venv/bin/python -u verify_setup.py
.\.venv\Scripts\python.exe with_timeout.py 120 .\.venv\Scripts\python.exe -u verify_setup.py

It ends with OK only if the device opened, reported status OK and device error 0, and — on luminescence and fluorescence devices — completed a measurement. On an Absorbance 96 Automate with an empty slot it runs an initialise, which needs no plate. On absorbance devices it prints NOT MEASURED: a meaningful absorbance check needs a plate or cuvette, so use the complete example for that.