From 4864916a3a6cde79d48bedc5a2e16bf91ba7bbce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Delf=20Neum=C3=A4rker?= Date: Thu, 1 Oct 2026 10:04:20 +0200 Subject: [PATCH] Add SDK guide for the Byonoy device library --- 70-byonoy.rules | 24 + README.md | 484 +++++++++++++++ SDK_NATIVE.md | 311 ++++++++++ SDK_PYTHON.md | 1502 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 2321 insertions(+) create mode 100644 70-byonoy.rules create mode 100644 README.md create mode 100644 SDK_NATIVE.md create mode 100644 SDK_PYTHON.md diff --git a/70-byonoy.rules b/70-byonoy.rules new file mode 100644 index 0000000..7f78a2f --- /dev/null +++ b/70-byonoy.rules @@ -0,0 +1,24 @@ +# Byonoy device access rules. +# +# version: 1 + +SUBSYSTEM=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="ab12", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="106a", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="1199", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="119a", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="119b", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="12f1", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="12f2", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="12f3", GROUP="dialout", MODE="0660", TAG+="uaccess" + +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="ab12", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="106a", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="1199", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="119a", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="119b", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="12f1", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="12f2", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="12f3", GROUP="dialout", MODE="0660", TAG+="uaccess" + +SUBSYSTEM=="usb", ATTRS{idVendor}=="0403", ATTRS{manufacturer}=="Byonoy GmbH", GROUP="dialout", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{manufacturer}=="Byonoy GmbH", GROUP="dialout", MODE="0660", TAG+="uaccess", ENV{ID_MM_DEVICE_IGNORE}="1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..644c199 --- /dev/null +++ b/README.md @@ -0,0 +1,484 @@ +# Byonoy Device Library — SDK guide + +How to obtain and safely use the Byonoy device SDKs. Start here, then follow the +link for the form you need: + +- **[Python SDK](SDK_PYTHON.md)** — a wheel you `import`. Scripting, tests, automation. +- **[Native SDK](SDK_NATIVE.md)** — headers and a shared library. C, C++, or any FFI. + +Both drive the same library and the same devices. + +These documents are deliberately explicit: they spell out defaults, return +conventions and failure modes that are easy to get wrong, so that they can be +followed without any other context — whether you are a developer integrating a +device or a coding agent writing code against it. If something you need is not +covered here, ask Byonoy rather than inferring it. + +> **Manual Absorbance 96: use the serial protocol, not this SDK.** The manual +> variant of Absorbance 96 is driven **only** through a plain-text serial +> protocol, documented publicly in +> **[abs96serial](https://git.byonoy.com/public/abs96serial)**; the SDK does not +> support it. That protocol is used by no other Byonoy device — the Absorbance +> 96 Automate and every other device are driven through the SDK described here. + +--- + +## 1. Which variant: public or internal + +The library ships in two variants. + +**The public variant is what is generally available outside Byonoy, and it is +enough for everyday operation.** It covers the whole normal working life of an +instrument: + +- discovering connected devices and opening them +- device information, status, errors, temperature, humidity, uptime, slot and + alignment state +- every measurement modality the hardware offers — absorbance (Absorbance One + and Absorbance 96 Automate), luminescence, fluorescence +- firmware update + +If your job is "talk to the instrument and take readings", the public variant is +the right answer and the rest of this section does not apply to you. + +**The internal variant is available on request.** Ask Byonoy for it if you run +into a specific limitation the public variant cannot express — not as a default +choice. It is a narrower, less travelled path, and its extra surface can change +without the consideration given to the public API. + +What it adds is described where it is relevant: see the internal chapter of the +[Python](SDK_PYTHON.md) or [Native](SDK_NATIVE.md) document. + +--- + +## 2. How to get it + +| What | How | +|---|---| +| **Public Python wheel** | Self-serve, no credentials — the public package registry | +| **Internal Python wheel** | On request from Byonoy; installed from the internal registry with a token | +| **Native SDK, either variant** | On request from Byonoy | + +Only the public Python wheel is self-serve today. The native archive is not +currently published to a location you can reach without Byonoy handing it to +you, whichever variant you need. + +The per-form documents cover each of these in detail. + +### Credentials + +Nothing you install anonymously needs a token. For anything Byonoy provides from +the internal registry you will be given a `git.byonoy.com` account or token; the +per-form documents show where it goes. Export it as: + +```bash +export BYONOY_USER='' +export BYONOY_TOKEN='' +``` + +```powershell +$env:BYONOY_USER='' +$env:BYONOY_TOKEN='' +``` + +Commands in these documents are written for **bash** (Linux, macOS). Where +Windows differs, the Python document gives the PowerShell equivalent. + +--- + +## 3. How the library works + +These apply to both forms and both variants. The per-form documents show the +code; this is the behaviour behind it. + +**A device must be physically attached over USB.** There is no simulator. An +empty device list is the normal answer when nothing is plugged in, not an error. +For prototyping in Python, a small mock can stand in for a device — see +[Prototyping without a device](SDK_PYTHON.md#7-prototyping-without-a-device). + +**The lifecycle is discover → open → use → close.** Opening gives you a handle; +everything else takes that handle; closing invalidates it. Using a closed handle +returns `INVALID_ARGUMENT`, not `DEVICE_CLOSED`. + +**Check every return code.** Almost every call reports one. A value handed back +alongside a non-`NO_ERROR` code is meaningless — and it is not empty: a failed +96-well measurement still hands back 96 zeros, so testing the result's length +or truthiness does not tell you it worked. + +**Ask what the device supports before asking it to do something.** Each +modality and feature has a `*_supported` predicate; calling something the device +does not support gives `UNSUPPORTED_OPERATION`. Do not branch on the model name. + +- **Decide the modality from the `*_measurement_supported` predicates only.** + Helper predicates can answer `True` on a device that cannot perform the + modality: an Absorbance One reports `abs96_available_wavelengths_supported` + as `True`, and an Absorbance 96 Automate reports + `absone_available_wavelength_supported` as `True`, while the corresponding + `*_measurement_supported` is `False`. +- `abs96_modules_supported`, `abs96_get_modules` and `abs96_setup_modules` concern + the Absorbance 96's wavelength modules and are not needed for measuring. Do + not call `abs96_setup_modules` unless Byonoy asks you to. +- **Discovery and device information can name different types.** Discovery + (`available_devices()`) reports the type from the USB IDs alone, and every + Absorbance One is reported there as `AbsorbanceOneOr96`. Once the device is + open, `get_device_information()` reports what it actually is + (`AbsorbanceOne`). Treat the latter as authoritative. + +**One open handle per device.** What happens when you try a second one +depends on who holds the first: + +- another handle in **your own process** → `DEVICE_ALREADY_OPEN` (`0x0104`) +- the device held by **another process**, on Linux and macOS → + `DEVICE_COMMUNICATION_FAILURE` (`0x0006`) +- the device held by **another process, on Windows** → the second open + **succeeds**, and both processes can talk to the device at once. Nothing + stops them interleaving commands. On Windows, making sure only one program + uses a device is your job. + +`0x0006` does not name its cause. Before treating it as a hardware fault, check +the usual ones: another program — a stray REPL, a previous run, another tool — +still has the device open; on Linux, the device is not accessible to your user +(see [Linux permissions](#linux-permissions)); on macOS, nobody is logged in at +the machine (see below). + +**Measurements block, and can take a long time.** Ctrl-C will not interrupt a +measurement in progress — SIGINT is ignored while the call is inside the +library. Size timeouts from the mode you actually use. + +The luminescence and fluorescence modes set an integration time: +`RAPID` 100 ms, `SENSITIVE` 2 s, `ULTRA_SENSITIVE` 20 s, or +`custom_integration_time_ms` for `CUSTOM` (at least 50). How that turns into a +duration differs between the two devices. + +**Luminescence 96** reads the plate through 16 detector channels of 6 wells +each, one channel after another, and spends the integration time — but at +least 300 ms — on each channel in use. With the device's sampling overhead a +run takes roughly *(channels used) × max(integration time, 300 ms) × 1.9*, so a +full plate is about 30 × the integration time, and `RAPID` is limited by the +300 ms floor. A channel counts as used when any of its 6 wells is selected; +which wells share a channel is fixed by the hardware, so selecting fewer wells +saves time only when it frees whole channels: + +| Mode | Integration time | Full plate | +|---|---|---| +| `RAPID` | 100 ms | ~10 s | +| `SENSITIVE` | 2 s | ~60 s | +| `ULTRA_SENSITIVE` | 20 s | **~10 min** | +| `CUSTOM` | `custom_integration_time_ms`, at least 50 | ~30 × that, plus overhead | + +**Fluorescence 96** steps across the plate one column at a time and measures +every column that contains **at least one selected well**; columns with no +selected well are skipped entirely. A run therefore takes roughly +*(number of columns used) × (time per column)*, plus a short start-up, and the +time per column grows with the integration time. Deselecting wells within a +column that is measured anyway saves nothing — to shorten a run, leave out +whole columns. The exact time per column depends on the device's calibration, +so it differs between units. + +**Absorbance:** + +| Device | Call | Duration | +|---|---|---| +| Absorbance 96 Automate | initialise, one wavelength | 1.3–2 s (~2.7 s with a reference wavelength) | +| Absorbance 96 Automate | initialise, two wavelengths | ~3 s | +| Absorbance 96 Automate | measure, one wavelength | ~1.2 s (~0.7 s in rapid mode) | +| Absorbance 96 Automate | measure, two wavelengths | ~2.8 s | +| Absorbance One | measure | under 5 s | + +- **Do not stop a long measurement early.** An `ULTRA_SENSITIVE` read of a full + plate takes about 10 minutes. On a test unit, one that was ended after 9 + minutes left the device unable to measure until it was power-cycled (see + [When the device stops measuring](#when-the-device-stops-measuring)). If + 10 minutes per read is too long for you, use `SENSITIVE` or select fewer + wells. +- **`CUSTOM` is rejected below 50 ms** (`INVALID_ARGUMENT`). There is no upper + limit — the device splits a long integration into shorter steps — and the + duration follows the formulas above. +- **A measurement can block indefinitely instead of failing.** On an Absorbance + One in an error state, `absone_measure` never returned — without an + initialise it did **not** return `NOT_INITIALIZED` — whether or not + initialisation had been attempted. Treat it as a rule: call `absone_measure` + only when the device status is `OK` and `absone_is_initialized` reports + `True` (see [Device status and device error](#device-status-and-device-error)). + +**Stopping a blocked measurement is a last resort.** The only way out of a call +that does not return is to end the process; the library's own internal waits +do not make it return. Ending the process works — SIGTERM or SIGKILL on Linux +and macOS, `Stop-Process` on Windows. The library gets no chance to +clean up, and the device may be left unable to measure, or may reset itself +— dropping off USB and re-enumerating a few seconds to a minute later. Plan for +a replug afterwards. In a virtual machine with USB passthrough, a device that +re-enumerates comes back to the **host**, not the VM, and has to be attached +to the VM again. If you run unattended, run each +measurement in a child process under a timeout (the Python document shows a +portable way) so that a hang costs you the measurement, not the whole program. + +**The library does not check for a plate,** and no device refuses to measure +without one. Absorbance measurements on an empty slot return `NO_ERROR` and +near-zero values that mean nothing; a Luminescence 96 cannot even tell whether +a plate is inserted, and without one returns background noise around zero. +Where the device can sense the slot, checking it before measuring is your job; +the complete Python example shows how. + +**A measurement returns a fixed-size result.** A 96-well read gives 96 values +whatever you selected; index `i` of the result corresponds to index `i` of your +selection. Deselecting wells does not shorten the list. + +### Well order + +**Results are in row-major order.** On a 96-well plate (8 rows × 12 columns), +index `i` is row `i // 12` and column `i % 12`: + +| Index | 0 | 1 | … | 11 | 12 | … | 95 | +|---|---|---|---|---|---|---|---| +| Well | A1 | A2 | … | A12 | B1 | … | H12 | + +```python +def well_name(i, columns=12): + return f"{chr(ord('A') + i // columns)}{i % columns + 1}" +``` + +The same order applies to `selected_wells`: entry `i` selects well `i` in the +table above. It holds for every 96-well modality (absorbance, luminescence, +fluorescence). The device sends one plate row at a time and nothing between it +and your code reorders the values, so the order is fixed by the plate, not by +the readout. + +- **Bottom readout needs no correction.** A device that reads the plate from + below mirrors the data itself before sending it. The result is already in + plate orientation; do not flip it again because `is_bottom_readout` is set. + +### Deselected wells + +A deselected well keeps its place in the result, but what it contains differs +by modality — do not treat it as a reading: + +| Modality | Deselected well contains | +|---|---| +| Luminescence 96 | `0` | +| Fluorescence 96 | `NaN` | +| Absorbance 96 Automate | no well selection; every well is measured | + +Because Luminescence 96 reports deselected wells as `0`, you cannot tell one +from a genuine dark reading by value. Keep your own selection and use it to +filter the result. + +### Units + +| Modality | Unit | Meaning | +|---|---|---| +| Absorbance (Absorbance 96 Automate, Absorbance One) | OD — optical density | how much light the sample absorbs, on a log₁₀ scale: 0 lets all light through, 1 lets 10 % through, 2 lets 1 % through | +| Luminescence | RLU — relative light units | how much light the sample emits, on the instrument's own scale | +| Fluorescence | RFU — relative fluorescence units | how much light the sample emits when excited, on the instrument's own scale | + +RLU and RFU are not calibrated physical units: compare readings taken on the +same device, in the same mode (and for fluorescence, the same filter set), +rather than across devices or settings. + +### Device status and device error + +Two calls describe the device's own health, separately from the return code of +any single call: + +**`get_device_status`** asks the device and gives a state: + +| State | Meaning | +|---|---| +| `OK` | the device reports no error | +| `ERROR` | the device reports an error — see `get_device_error` | +| `BROKEN_FW` | the firmware is corrupted or unidentifiable; the device needs a firmware update | +| `UNKNOWN` | the device did not answer the status request | + +Anything but `OK` means: do not measure. + +**`get_device_error`** gives the last error recorded for the device, from a +small set shared by all devices. It does not ask the device itself — call +`get_device_status` first for a current value. + +| Device error | Meaning | +|---|---| +| `0` | no error | +| `0x8001` (32769) | the device reported an error of its own — the common case; see below | +| `0x8004` (32772) | the device did not respond | +| `0x8009` (32777) | the device was disconnected | +| `0x8000` (32768) | unknown error | + +These numbers are **not** the library's return codes listed under +[Error codes worth recognising](#error-codes-worth-recognising), even where +they coincide (`0x8001` there is `MEASUREMENT_SLOT_NOT_EMPTY`). + +**What the device actually reported** is not available through the API. It +appears only in the protocol log (`enable_logging`), as a text ID such as +`com.byonoy-AbsOne-MIN_LIGHT_ERROR`. For the two absorbance devices, these mean: + +| Text ID | Meaning | +|---|---| +| `AbsOne-AMBIENT_LIGHT_ERROR` | too much ambient light is reaching the device, or it is defective | +| `AbsOne-MIN_LIGHT_ERROR` | too little light: the device is dirty, the slot is occupied, or it is defective | +| `AbsOne-TIMEOUT_ERROR` | the measurement was disrupted, e.g. by a shadow in the device | +| `AbsOne-NOISE_LIMIT_ERROR` | USB cable or hub problem, or a defective device | +| `AbsOne-HARDWARE_ERROR`, `AbsOne-UNRECOVERABLE_ERROR` | the device is defective; nothing the user can do | +| Absorbance 96: calibration failed | initialise failed — usually a plate in the slot or a dirty slot | +| Absorbance 96: ambient light | too much light is entering the device | +| Absorbance 96: USB power | insufficient USB power — use another port or a powered hub | +| Absorbance 96: temperature | the device temperature is outside its specified range | +| Absorbance 96: measurement unit | no contact with the upper section — make sure the device parts are aligned and seated | +| Absorbance 96: hardware | hardware defect — contact support | + +For the luminescence and fluorescence devices the log shows only the raw +firmware code, as `errorCode=0x…`. On a **Luminescence 96** it is a single +value: + +| Code | Meaning | Cleared by | +|---|---|---| +| `0x1` | calibration missing — the detector calibration could not be loaded | a power cycle; report it | +| `0x7` | shutter error — the shutter failed to move after repeated retries during a measurement | a power cycle | + +On a **Fluorescence 96** it is a set of flags, several of which can be set at +once: + +| Bit | Value | Meaning | +|---|---|---| +| 0 | `0x01` | calibration missing | +| 1 | `0x02` | configuration — the device's identity or board revision could not be read | +| 2 | `0x04` | homing — the slide is not homed; check that it moves freely and the transport lock is out | +| 3 | `0x08` | detector — usually the detector sled's supply or connection rather than the photodiodes | +| 4 | `0x10` | hall position — the position sensor does not answer | +| 5 | `0x20` | accelerometer — the accelerometer does not answer | +| 6 | `0x40` | photodiode compensation — no excitation light seen | +| 7 | `0x80` | motion system — the slide stalled, or homing never found its endstop | + +A Fluorescence 96 clears all flags at the start of every measurement and on a +restart, so a code seen after a failed run describes that run. Any flag +aborts a running measurement. Report codes to Byonoy support with the serial +number and firmware version. + +`ERROR` clears by itself once the device stops reporting the condition; no +reconnect is needed. A device that keeps reporting one — such as an Absorbance +One with a dirty optical path — stays in `ERROR` across closing, reopening and +new processes. It still answers queries, but its measurements fail or hang. +The opposite case — status `OK`, error `0`, yet every measurement fails — is +described under [When the device stops measuring](#when-the-device-stops-measuring). + +### Error codes worth recognising + +The library defines 25 error codes; these are the ones you are most likely to +meet. Print the name and number (`rc.name`, `hex(rc.value)` in Python) rather +than assuming an unlisted value is impossible. + +| Code | Name | Usual cause | +|---|---|---| +| `0x0000` | `NO_ERROR` | success | +| `0x0002` | `DEVICE_CLOSED` | device unplugged or lost while open | +| `0x0003` | `INVALID_ARGUMENT` | a handle you already freed, a config you did not create, or a value the device does not accept (see also the Absorbance 96 initialise note in the Python document) | +| `0x0005` | `UNSUPPORTED_OPERATION` | wrong modality or feature for this device | +| `0x0006` | `DEVICE_COMMUNICATION_FAILURE` | another process holds the device; on Linux, missing permissions; on macOS, nobody logged in to a desktop session; otherwise cable, power, or a device mid-reset | +| `0x0007` | `DEVICE_OPERATION_FAILED` | the device refused the operation. If the device status is `ERROR`, that is the cause; if it is `OK` and every measurement fails this way, see [When the device stops measuring](#when-the-device-stops-measuring) | +| `0x0101` | `DEVICE_NOT_FOUND` | nothing matching attached | +| `0x0104` | `DEVICE_ALREADY_OPEN` | your own process already has it open | +| `0x8001` | `MEASUREMENT_SLOT_NOT_EMPTY` | absorbance: initialising with a plate or cuvette in the slot | +| `0x8002` | `NOT_INITIALIZED` | absorbance: measuring a wavelength that was not initialised **on this handle** — initialisation does not survive closing the device | +| `0x8003` | `MEASUREMENT_PARTS_NOT_ALIGNED` | the device's parts are not seated correctly | + +### Linux permissions + +USB access is root-only by default. Without a udev rule the device still +appears in `available_devices()`, but with an **empty serial number**, and +opening it fails with `DEVICE_COMMUNICATION_FAILURE` (`0x0006`). Rather than +running as root, install the suggested rules in +**[`70-byonoy.rules`](70-byonoy.rules)** — the same rules the Byonoy desktop +app installs. The file sits next to this guide; copy it onto the machine, then: + +```bash +sudo install -m 0644 70-byonoy.rules /etc/udev/rules.d/70-byonoy.rules +sudo udevadm control --reload-rules +sudo udevadm trigger --subsystem-match=usb --subsystem-match=hidraw --subsystem-match=tty +sudo usermod -aG dialout "$USER" # needed for SSH, services and CI; then log in again +``` + +Check that it took effect, in a new login: + +```bash +id | grep -o dialout # your account is in the group +lsusb -d 16d0: # e.g. "Bus 003 Device 006: ID 16d0:106a …" +ls -l /dev/bus/usb/003/006 # the Bus/Device numbers from lsusb: group dialout +``` + +The library talks to the device through libusb, so the USB node under +`/dev/bus/usb` is the one whose permissions matter. The device's +`/dev/hidraw*` node is not a reliable check: it disappears while a program has +the device open, and can stay missing after a program was killed even though +the device works. If the USB node's group is not `dialout`, unplug the device +and plug it back in. + +An empty serial number is not always a permissions problem. After a process +was ended in the middle of a measurement, a device can be listed with an empty +serial number while opening it returns `DEVICE_COMMUNICATION_FAILURE` +(`0x0006`) or `DEVICE_NOT_FOUND` (`0x0101`) — typically because it is resetting +and about to drop off USB and re-enumerate. If permissions were fine before, +wait a minute and look again; if it does not come back, replug it. + +The rules cover Byonoy devices by USB vendor and product ID (vendor `16d0`, +plus `0483:ab12`), for both the raw USB and the `hidraw` device nodes. They +grant access in two ways: + +- `TAG+="uaccess"` gives the user logged in at the machine's own seat access + automatically. This does **not** apply to SSH sessions, containers, CI + runners or services. +- `GROUP="dialout"` gives access to members of the `dialout` group — the + `usermod` line above. Anything that is not a local desktop session depends on + it. + +The last two lines match the manual Absorbance 96's serial adapter (vendor +`0403`, manufacturer `Byonoy GmbH`) and stop ModemManager from probing it. They +matter only for the [serial protocol](https://git.byonoy.com/public/abs96serial), +not for the SDK. + +### macOS and Windows + +Neither needs a driver or a permission step. On **macOS**, though, the device +can only be opened while a user is logged in to a desktop session on the Mac. +With nobody logged in — for example over SSH right after a restart — the device +is still listed by `available_devices()`, but opening it fails with +`DEVICE_COMMUNICATION_FAILURE` (`0x0006`). For an unattended Mac, enable +automatic login. + +### When the device stops measuring + +**This is not normal operation.** A healthy instrument measures every time, and +you should not need this section. It is here so that if you meet the state, you +recognise it rather than concluding your code is wrong. + +A device can end up unable to measure while every diagnostic insists it is fine: +**every** measurement fails immediately with `DEVICE_OPERATION_FAILED`, in fresh +processes too, yet the device status reads OK, the handle reports open, and the +device error accessor reports nothing. With protocol logging on, the device +refuses each measurement request with return code 3. + +To confirm the state quickly on a luminescence or fluorescence device, measure +with **no wells selected**: a healthy device returns `NO_ERROR` within a +fraction of a second; a device in this state returns `DEVICE_OPERATION_FAILED` +just as fast. + +It has been seen after a measurement was stopped by ending the process — +including a long `ULTRA_SENSITIVE` read stopped before it finished. Waiting +does not clear it; a power cycle does. + +Recovery is a device reboot. The internal variant can do this from software +(`reboot()`); **the public variant cannot** — unplug the device and plug it back +in. Either way it re-enumerates within a few seconds and you open it again. + +> **Report it if it recurs.** A one-off on an engineering or pre-production unit +> is not remarkable. A device that needs rebooting repeatedly, or one on which a +> particular mode fails consistently, is a fault worth reporting to Byonoy +> support with the serial number, the firmware version from the device +> information call, the mode and well selection you used, and how often it +> happens. Do not work around it with a reboot loop — that hides a hardware +> problem behind software. + +--- + +## 4. Next + +- **[Python SDK →](SDK_PYTHON.md)** — including a [complete measure-to-CSV program](SDK_PYTHON.md#6-complete-example-measure-and-export-csv) covering every device +- **[Native SDK →](SDK_NATIVE.md)** diff --git a/SDK_NATIVE.md b/SDK_NATIVE.md new file mode 100644 index 0000000..aff9034 --- /dev/null +++ b/SDK_NATIVE.md @@ -0,0 +1,311 @@ +# Byonoy Device Library — Native 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 +C/C++ specifics. + +Everything up to section 6 is the **public** variant. Section 6 covers what the +internal variant adds. + +--- + +## 1. Getting the SDK + +**The native SDK is available on request, in either variant.** It is not +published anywhere you can fetch it from without Byonoy providing it. Ask for +the variant you need and you will be given an archive, +`byonoy-devices-public-sdk-.zip` or +`byonoy-devices-internal-sdk-.zip`. + +If you have been given access to the internal repository's releases, the archive +is attached to each release and can be downloaded with your token: + +```bash +TAG=$(curl -fsS -H "Authorization: token $BYONOY_TOKEN" \ + 'https://git.byonoy.com/api/v1/repos/sw/byonoy_device_library/releases?limit=1' \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["tag_name"])') + +curl -fsSL -H "Authorization: token $BYONOY_TOKEN" -o sdk.zip \ + "https://git.byonoy.com/sw/byonoy_device_library/releases/download/${TAG}/byonoy-devices-public-sdk-${TAG}.zip" +unzip -q sdk.zip -d sdk +``` + +Use that `releases/download/...` URL; the `/api/v1/.../releases/assets/` +endpoint returns `404` on this instance. + +`limit=1` takes the newest release, which may be a pre-release (a tag with a +`-suffix`). Public archives are attached to final tags; a recent pre-release tag +carries internal artifacts only. Check what a release actually has rather than +assuming. + +--- + +## 2. Layout + +One archive carries every platform the release was built for; take the files for +yours, and check yours is present before building. + +``` +sdk/include/byonoy_device_library.h the API +sdk/lib/libbyonoy_device_library.so Linux +sdk/lib/libbyonoy_device_library.dylib macOS +sdk/bin/libbyonoy_device_library.dll Windows runtime +sdk/lib/libbyonoy_device_library.dll.a Windows import library +sdk/lib/libhidapi* dependency, ships alongside +sdk/examples/C, sdk/examples/C++ compilable references +sdk/third-party-licenses/ notices for bundled dependencies +``` + +The internal archive is the same with `_internal` appended to every library +name, plus a second header. `bin/` holds the Windows runtime only — on macOS and +Linux everything you need is in `lib/`. + +--- + +## 3. Build and run + +Compiling is not enough: **you must also tell your binary where to find the +library at run time.** The shipped library's install name is +`@rpath/libbyonoy_device_library.dylib`, and its own RPATH entries only cover how +*it* finds hidapi — they do nothing for your executable. Link without an RPATH of +your own and you get a clean compile followed by: + +``` +dyld: Library not loaded: @rpath/libbyonoy_device_library.dylib + Reason: no LC_RPATH's found +``` + +Give the executable an RPATH, relative to itself so the result stays portable: + +```bash +# macOS +cc -I sdk/include main.c -L sdk/lib -lbyonoy_device_library \ + -Wl,-rpath,@executable_path/sdk/lib -o demo + +# Linux +cc -I sdk/include main.c -L sdk/lib -lbyonoy_device_library \ + -Wl,-rpath,'$ORIGIN/sdk/lib' -o demo +``` + +Adjust the RPATH to wherever `lib/` sits relative to the finished binary. As a +throwaway alternative you can set the loader path in the environment, but this +does not travel with the binary: + +```bash +DYLD_LIBRARY_PATH=sdk/lib ./demo # macOS +LD_LIBRARY_PATH=sdk/lib ./demo # Linux +``` + +On Windows, put `sdk/bin` on `PATH`; the DLLs there must travel with the +executable. + +The examples below use `uint32_t` and `true`, which the SDK header provides on a +current compiler. Add `#include ` and `` if yours is older +than C23. + +--- + +## 4. Smallest complete program + +```c +#include "byonoy_device_library.h" +#include + +int main(void) { + byonoy_device_t* devices = NULL; + uint32_t count = 0; + byonoy_available_devices(&devices, &count); /* returns void */ + if (count == 0) { + printf("no device\n"); + byonoy_free_available_devices(); /* allocated even when empty */ + return 1; + } + + byonoy_device_handle_t handle; + byonoy_error_code rc = byonoy_open_device(devices, &handle); + byonoy_free_available_devices(); /* list is dead once opened */ + if (rc != BYONOY_ERROR_NO_ERROR) { + printf("open failed: 0x%04x\n", rc); + return 1; + } + + byonoy_device_info_t* info = NULL; + rc = byonoy_create_device_information(&info); + if (rc != BYONOY_ERROR_NO_ERROR) { /* create/free pair */ + printf("allocation failed: 0x%04x\n", rc); + byonoy_free_device(handle); + return 1; + } + + rc = byonoy_get_device_information(handle, info); + if (rc == BYONOY_ERROR_NO_ERROR) { + printf("%s %s %s\n", info->ref_no, info->sn, info->version); + } else { + printf("device info failed: 0x%04x\n", rc); + } + + byonoy_free_device_information(info); + byonoy_free_device(handle); + return rc == BYONOY_ERROR_NO_ERROR ? 0 : 1; +} +``` + +`byonoy_device_info_t` carries `sn`, `ref_no` and `version` as `const char*`, +and `type` as a `byonoy_device_types` enum. + +### Memory rules + +- Anything returned through a pointer has a **`create`/`free` pair**. Call + `create` first, `free` exactly once, and never free something you did not + create. +- Functions returning `int`, `float` or `bool` through a pointer allocate + nothing. +- `byonoy_available_devices()` allocates a list; release it with + `byonoy_free_available_devices()` once you have opened what you need. The + `byonoy_device_t*` entries are invalid afterwards — the **handle** is what + stays valid. +- `byonoy_free_device(handle)` closes the device. The handle is invalid after + that, and calls with it return `BYONOY_ERROR_INVALID_ARGUMENT` (`0x0003`) — + *not* `DEVICE_CLOSED`, which is what a disconnected but still-open device + gives you. + +--- + +## 5. Taking a measurement + +Gate on the predicate, `create` the config and result, measure, free both. A +complete program, so it can be compiled as it stands: + +```c +#include "byonoy_device_library.h" +#include + +int main(void) { + byonoy_device_t* devices = NULL; + uint32_t count = 0; + byonoy_available_devices(&devices, &count); + if (count == 0) { printf("no device\n"); byonoy_free_available_devices(); return 1; } + + byonoy_device_handle_t handle; + byonoy_error_code rc = byonoy_open_device(devices, &handle); + byonoy_free_available_devices(); + if (rc != BYONOY_ERROR_NO_ERROR) { printf("open failed: 0x%04x\n", rc); return 1; } + + if (!byonoy_lum96_measurement_supported(handle)) { + printf("this device does not do 96-well luminescence\n"); + byonoy_free_device(handle); + return 1; + } + + byonoy_lum96_measurement_config_t* config = NULL; + byonoy_lum96_measurement_result_t* result = NULL; + if (byonoy_create_lum96_measurement_config(&config) != BYONOY_ERROR_NO_ERROR || + byonoy_create_lum96_measurement_result(&result) != BYONOY_ERROR_NO_ERROR) { + printf("allocation failed\n"); + byonoy_free_device(handle); + return 1; + } + + config->mode = BYONOY_LUM96_INTEGRATION_RAPID; /* ~10 s; SENSITIVE is ~60 s */ + for (int i = 0; i < 96; ++i) config->selected_wells[i] = true; + + rc = byonoy_lum96_measure(handle, config, result); + if (rc == BYONOY_ERROR_NO_ERROR) { + for (int i = 0; i < 96; ++i) { + printf("%8.1f", result->value[i]); + if ((i + 1) % 12 == 0) printf("\n"); + } + } else { + printf("measurement failed: 0x%04x\n", rc); + } + + byonoy_free_lum96_measurement_result(result); + byonoy_free_lum96_measurement_config(config); + byonoy_free_device(handle); + return rc == BYONOY_ERROR_NO_ERROR ? 0 : 1; +} +``` + +**Set both `mode` and `selected_wells` explicitly.** A freshly created config +has every well deselected, and measuring with it *succeeds* — returning +instantly with 96 zeros and no error. Its default mode is +`BYONOY_LUM96_INTEGRATION_RAPID` (0), which differs from the Python binding's +default; do not rely on either. + +The mode enum is +`BYONOY_LUM96_INTEGRATION_{RAPID,SENSITIVE,ULTRA_SENSITIVE,CUSTOM}`, and +`result->value` is a fixed `float[96]` in row-major +[well order](README.md#well-order) (`value[0]` is A1, `value[12]` is B1), as +is `selected_wells`. The capability table in the +[Python document](SDK_PYTHON.md#which-modality-does-this-device-support) is also +the C capability table, with `byonoy_` prefixes. + +### Bundled examples + +`sdk/examples/` holds compilable references, one directory each with a `main.c` +or `main.cpp` and a `CMakeLists.txt`: + +| Path | Shows | +|---|---| +| `examples/C/device-info` | open, read information, close | +| `examples/C/lum96-measurement` | 96-well luminescence | +| `examples/C/abs96-multi-measurement` | multi-wavelength absorbance | +| `examples/C/absone-measurement` | single-cuvette absorbance | +| `examples/C/device-update` | firmware update | +| `examples/C++/async` | asynchronous measurement (internal variant) | +| `examples/C++/rpc` | device RPC (internal variant) | + +They build with CMake, and CMake sets the RPATH for you — which makes this the +safer route than the `cc` line above: + +```bash +cmake -S sdk/examples/C/device-info -B build-example +cmake --build build-example +./build-example/device-information +``` + +Against the **internal** archive their `find_library` call fails, because they +look for the public library name while the internal archive ships +`libbyonoy_device_library_internal.*`. Point the cache variable at the real file: + +```bash +cmake -S sdk/examples/C/device-info -B build-example \ + -DBYONOY_DEVICE_LIBRARY="$PWD/sdk/lib/libbyonoy_device_library_internal.dylib" +``` + +--- + +## 6. The internal variant + +Available on request; see [the SDK guide](README.md#1-which-variant-public-or-internal) +for when you should want it. + +**The C API is identical.** Everything above applies unchanged except the +library name — link `-lbyonoy_device_library_internal` instead. The extra +functionality arrives as a *second* header: + +``` +sdk/include/byonoy_device_library.h same as public +sdk/include/byonoy_device_library_internal.h the additions +``` + +`byonoy_device_library_internal.h` is **C++, not C**. It declares +`namespace byonoy::device::library::internal` and uses `std::vector`, +`std::optional`, `std::filesystem` and `std::chrono` in its signatures, so a +translation unit including it must be compiled as C++. The public header remains +usable from C either way — the internal header wraps its include in +`extern "C"`. + +It adds its own error enum, `byonoy_internal_error_code`, based at `0x10000` +(`NOT_ENUMERATED`, `UNKNOWN_NAME`, `UNKNOWN_ID`, `REQUEST_FAILED`, `WRONG_TYPE`, +`READ_ONLY`, `WRITE_ONLY`, `FILE_READ_FAILED`, `FILE_WRITE_FAILED`, +`ASYNC_OPERATION_ALREADY_RUNNING`, …). These are distinct from the public codes +in the SDK guide's table, not additions to them. + +The feature areas — asynchronous measurements, data fields, files, RPC, +diagnostics, LEDs, bootloader and flashing, reboot — are listed with their entry +points in the [Python document](SDK_PYTHON.md#what-it-adds); the C++ names match +apart from the namespace. Each is gated on its own `*_supported` predicate. + +`sdk/examples/C++/async` and `sdk/examples/C++/rpc` are worked examples of two +of these, and are the best starting point for the C++ header. diff --git a/SDK_PYTHON.md b/SDK_PYTHON.md new file mode 100644 index 0000000..28570d6 --- /dev/null +++ b/SDK_PYTHON.md @@ -0,0 +1,1502 @@ +# 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.