# Byonoy Device Library — Python SDK Read the **[SDK guide](README.md)** 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: ```bash sudo apt update && sudo apt install -y python3-venv libhidapi-libusb0 # Debian, Ubuntu sudo dnf install hidapi # Fedora ``` Then, on Linux and macOS alike: ```bash python3 -m venv .venv ./.venv/bin/python -m pip install \ --index-url https://git.byonoy.com/api/packages/public/pypi/simple/ \ byonoy_devices ``` 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](README.md#macos-and-windows) in the SDK guide. On **Linux**, the device also needs permissions before you can open it — see [Linux permissions](README.md#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`: ```powershell 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](#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): ```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: ```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 rather than fighting the resolver. --- ## 2. Smallest complete program ```python 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: ```python 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](README.md#well-order) — row-major, so entry 0 is A1 and entry 12 is B1: ```python 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](README.md#3-how-the-library-works)). ### Absorbance Absorbance needs an initialise with the slot **empty**, then a measure with the plate (or cuvette) **in**. For an Absorbance 96 Automate: ```python 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](#6-complete-example-measure-and-export-csv) 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: ```python 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: ```python 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](README.md#3-how-the-library-works)). 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`: ```python """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 ```python 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](README.md#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](README.md#1-which-variant-public-or-internal) 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: ```bash 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 ``` ```python 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` | ```bash 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. ```python 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](README.md#when-the-device-stops-measuring), without unplugging anything: ```python 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: ```bash ./.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 ``` ```powershell .\.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. ```python #!/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](README.md#well-order); units are explained under [Units](README.md#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: ```python """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: ```python 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`: ```python """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](#6-complete-example-measure-and-export-csv) against a simulated Absorbance 96: ```bash BYONOY_MOCK_DEVICE=Absorbance96 ./.venv/bin/python run_with_mock.py measure_to_csv.py --output mock-plate.csv ``` ```powershell $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 `ErrorCode`s 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](README.md#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?](#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): ```python """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: ```bash ./.venv/bin/python with_timeout.py 120 ./.venv/bin/python -u verify_setup.py ``` ```powershell .\.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](#6-complete-example-measure-and-export-csv) for that.