From d4e3880a499fc0d42800a392b523b7d561568c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 19:08:02 +0200 Subject: [PATCH 01/26] chore: gitignore stray lsl-dummy-stream binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust lsl-dummy-stream (Zarr-replay tool from the separate lsl-recording-toolbox repo) is not a MyoGestic dependency — nothing in the package, examples, tests, or docs references it. MyoGestic's EMG simulator is the cross-platform Python myogestic.tools.emg_generator. Ignore the path so the platform-specific binary can't be committed by accident. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index f74ed30..9a44892 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,10 @@ site/ # virtual_hand() defaults to this path; override with $VHI_PATH. tools/MyoGestic-VHI +# Stray LSL recording-toolbox binaries (separate Rust repo; not a MyoGestic +# dep — MyoGestic's EMG simulator is the Python myogestic.tools.emg_generator). +tools/lsl-dummy-stream + # Browser playground - locally built MyoGestic wheel + manifest. # CI generates both fresh each deploy; committing would just bitrot. docs/playground/wheels/*.whl From e3cb6207ef1c354309dea1820a1966084f3fb40b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 21:40:50 +0200 Subject: [PATCH 02/26] docs(spec): design for native OTB device sources (Muovi/Quattrocento) Pure-Python (no Qt) Source implementations for OT Bioelettronica devices, re-adding pre-2.0 hardware connectivity. Captures the exact wire protocol (socket roles, config bytes, frame formats, endianness, conversion factors, Quattrocento CRC-8) extracted from biosignal-device-interface, plus the shared-base architecture, data contract, GUI integration, and a phased hardware-validated test plan. --- .../2026-06-03-otb-device-sources-design.md | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-otb-device-sources-design.md diff --git a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md new file mode 100644 index 0000000..957a256 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md @@ -0,0 +1,247 @@ +# Design: Native OT Bioelettronica (OTB) Device Sources for MyoGestic 2.0 + +- **Date:** 2026-06-03 +- **Author:** Raul C. Sîmpetru (with Claude) +- **Status:** Draft — pending review +- **Topic:** Re-add the ability to connect to OT Bioelettronica devices (Muovi/Muovi+, Quattrocento, later Sessantaquattro) that existed before the MyoGestic 2.0 rewrite. + +## 1. Background + +Pre-2.0, MyoGestic connected to OTB hardware through the external Qt library +`biosignal-device-interface` ("bdi", `github.com/NsquaredLab/Biosignal-Device-Interface`). +The Qt GUI embedded an `OTBDevicesWidget` that owned device selection, socket +connection, configuration, and streaming; MyoGestic subscribed to its Qt signals +(`biosignal_data_arrived`, `connect_toggled`, `stream_toggled`, …) and read +`get_device_information()` for `{sampling_frequency, samples_per_frame, +number_of_biosignal_channels}`. + +The 2.0 rewrite replaced the **Qt** GUI with **Dear ImGui** and replaced the +signal-based device widget with a pull-based **`Source` Protocol** +(`myogestic/stream.py`): + +```python +class Source(Protocol): + def connect(self) -> StreamInfo: ... + def read(self) -> tuple[np.ndarray | None, np.ndarray | None]: ... + def disconnect(self) -> None: ... + # optional: discover() -> list[dict[str,str]], reconnect(target) -> StreamInfo +``` + +bdi is fundamentally Qt-coupled (its device classes subclass `QObject`, use +`PySide6.QtNetwork` sockets, deliver data via Qt `Signal`s, and need a running +Qt event loop), so it cannot be cleanly reused in the deliberately Qt-free 2.0 +app. `pyproject.toml` still carries an unused `bdi` optional extra (line 57). + +**Decision (approved):** Approach **C** — reimplement the OTB devices as native, +pure-Python `Source`s using the stdlib `socket` module, borrowing the wire- +protocol constants from bdi. No Qt, no new runtime dependency. + +## 2. Goals / Non-goals + +**Goals** +- Connect to Muovi/Muovi+ and Quattrocento from inside MyoGestic 2.0 as `Source`s. +- Stay Qt-free and single-process; no new runtime dependencies (stdlib `socket` + `numpy`). +- Restore the "connect from MyoGestic" feel; get GUI status/scan/reconnect for free. +- Structure so Sessantaquattro (Muovi-family protocol) drops in later on the same base. + +**Non-goals (v1)** +- A full in-GUI device-configuration panel (working mode, grid selection). Config + lives in constructor kwargs for v1. +- Sessantaquattro implementation now (no test hardware available yet). +- Reusing bdi at runtime / reintroducing PySide6. + +## 3. Architecture & file layout + +Self-contained `myogestic/sources/otb/` package; thin device subclasses over a +shared base. + +``` +myogestic/sources/otb/ + __init__.py # exports MuoviSource, QuattrocentoSource + _base.py # _OTBSource: socket lifecycle, frame buffering, Source Protocol + _decode.py # pure functions: bytes -> (n_samples, n_channels) float32 + _constants.py # channel dicts, fs, conversion factors, command builders (from bdi) + muovi.py # MuoviSource(plus=False, ...) — TCP SERVER role + quattrocento.py # QuattrocentoSource(...) — TCP CLIENT role + CRC-8 +``` + +`_base.py` owns everything common: socket open/close, the `read()` byte-buffer +accumulation + complete-frame slicing, float32 conversion, per-frame timestamp +generation, and the `connect/read/disconnect/discover/reconnect` surface. Each +device file supplies: config-command bytes, frame geometry, endianness, +conversion factors, socket role. + +No new dependencies — `socket` (stdlib) + `numpy` (already core). The sources +need no `pyserial`/Qt/bdi, so they can ship in `sources/__init__.py` directly +(unlike `SerialSource`, which is opt-in for `pyserial`). + +## 4. Source API & data contract + +- `connect() -> StreamInfo` + - **Muovi**: bind/listen on `(host_ip, 54321)`, `accept()` the device (it dials + in), send idle config byte then start byte. Blocking is acceptable (matches + `LSLSource.connect`, which blocks up to 10 s). + - **Quattrocento**: `connect((device_ip, 23456))`, send the 40-byte config + (with CRC) + start. + - Returns `StreamInfo(n_channels, fs, dtype=np.float32, channel_names)`. +- `read() -> (data, ts)`: non-blocking poll. Drain socket into a `bytearray`, + slice **complete** frames, decode to **`(n_samples, n_channels)` sample-major + float32**. Return `(None, None)` when no full frame is ready. +- `disconnect()`: send stop (config byte `-1`; for Quattrocento recompute CRC), + close sockets. +- **Channels**: biosignal-only by default; `include_aux=True` appends aux + channels. `channel_names` labels biosignal (and aux, if included). +- **Timestamps**: device sends none. Per decoded frame of `N` samples, stamp with + `t_end = mne_lsl.lsl.local_clock()` and back-date: `ts[i] = t_end - (N-1-i)/fs`, + giving monotonic, `1/fs`-spaced timestamps in LSL clock units. + +## 5. Wire protocol (extracted from bdi `main`) + +### 5.1 Muovi / Muovi+ +- **Socket role:** host is **TCP server**; device dials in. Port **54321**. + Host NIC must share the device's subnet. +- **Handshake:** none; `accept()` then send config. Device streams only after a + config byte with the acquisition LSB set. +- **Config:** single big-endian byte `cfg = (working_mode << 2) + detection_mode`. + Start = resend `cfg + 1`; stop = resend `cfg - 1`. + - `MuoviWorkingMode`: NONE=0, EEG=1, EMG=2 + - `MuoviDetectionMode`: NONE=0, MONOPOLAR_GAIN_8=1, MONOPOLAR_GAIN_4=2, + IMPEDANCE_CHECK=3, TEST=4 + - Rule: EEG + GAIN_4 is coerced to GAIN_8. + - Examples (idle): EMG+gain8 = `0x09` (stream `0x0A`); EMG+gain4 = `0x0A` + (stream `0x0B`); EEG+gain8 = `0x05` (stream `0x06`). +- **Geometry:** Muovi = 32 biosignal + 6 aux = 38 total; Muovi+ = 64 + 6 = 70. + + | Device | Mode | total | bio | aux | Fs | bytes/sample | samples/frame | frame bytes | + |---|---|---|---|---|---|---|---|---| + | Muovi | EMG | 38 | 32 | 6 | 2000 | 2 (int16) | 18 | 1368 | + | Muovi | EEG | 38 | 32 | 6 | 500 | 3 (int24) | 12 | 1368 | + | Muovi+ | EMG | 70 | 64 | 6 | 2000 | 2 (int16) | 10 | 1400 | + | Muovi+ | EEG | 70 | 64 | 6 | 500 | 3 (int24) | 6 | 1260 | + +- **Sample format:** **big-endian, signed**; int16 (EMG) / int24 (EEG, + manual sign-extend). Frame is Fortran-order `(n_channels, samples_per_frame)`: + contiguous values are `[ch0_t0, ch1_t0, …, chN_t0, ch0_t1, …]`. +- **Conversion factor** (× raw → mV; same for bio & aux): + GAIN_8 = `572.2e-6`, GAIN_4 = `286.1e-6`. + ⚠️ bdi's dict and its docstrings disagree on which gain is finer; we replicate + the **dict** values to match bdi's numeric output and add a comment noting the + discrepancy. +- **Aux:** 6 channels appended after biosignal (rows 32..37 / 64..69), same int + width and timing, same conversion factor in bdi. +- **Stop/disconnect:** resend `cfg - 1`, drain, close. + +### 5.2 Quattrocento +- **Socket role:** host is **TCP client**; dials the device. Default + **169.254.1.10:23456** (link-local — host NIC needs a 169.254.x.x address). +- **Handshake:** none; after connect, send the 40-byte config; start toggles the + acquisition bit. +- **Config:** 40-byte packet. + - Byte 0 = `ACQ_SETT`: + ``` + acq = 1 << 7 + acq |= decim_active << 6 + acq |= recording_active << 5 + acq |= fs_mode(0..3) << 3 + acq |= n_channels_mode(0..3) << 1 + acq |= acquisition_active # bit0 (start/stop) + ``` + - Byte 1,2 = 0 (analog-out selectors, unused). + - Bytes 3–14 = IN1–IN4 (4×3-byte per-input config); 15–26 = IN5–IN8; + 27–38 = MULTIPLE IN 1–4 (4×3 bytes). Each 3-byte input config: + `byte3 = (side<<6)|(hp<<4)|(lp<<2)|detection`, bytes 1–2 = 0. + - Byte 39 = **CRC-8** over bytes 0..38 (poly `0x8C`, init 0, LSB-first): + ```python + def crc8(data, length): + crc = 0 + for j in range(length): + b = data[j] + for _ in range(8): + s = (crc & 1) ^ (b & 1) + crc >>= 1 + if s: crc ^= 0x8C + b >>= 1 + return crc + ``` + Start = `cfg[0] += 1`, recompute CRC, resend; stop = `cfg[0] -= 1`, + recompute CRC, resend. +- **Modes:** fs LOW=512 / MEDIUM=2048 / HIGH=5120 / ULTRA=10240 Hz; + streamed-channel count LOW=120 / MEDIUM=216 / HIGH=312 / ULTRA=408 (independent + of fs). Per-sample always int16 / 2 bytes; samples/frame = 64. + - Channel accounting: `streamed` = 120/216/312/408 (used for reshape); + `bio = len(grids)*64`; aux = 16; supplementary = 8. +- **Sample format:** **little-endian, signed int16** (` Date: Wed, 3 Jun 2026 21:47:36 +0200 Subject: [PATCH 03/26] docs(spec): correct Muovi gain mapping + add GUI device-config panel - Conversion factor: use the physically-correct gain->resolution mapping (gain-8 = 286.1 nV finer, gain-4 = 572.2 nV); bdi's dict had these swapped. OTB's protocol PDF is not public, but the 8:4 gain ratio settles it. - Promote an in-GUI device-config panel into scope: a source-agnostic config_spec()/set_config() optional Protocol extension + a generic device_config ImGui widget (Apply & Connect via reconnect()), plus a minimal manual-connect acquire-loop change behind a per-stream flag. --- .../2026-06-03-otb-device-sources-design.md | 74 ++++++++++++++----- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md index 957a256..974fc5f 100644 --- a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md +++ b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md @@ -45,11 +45,13 @@ protocol constants from bdi. No Qt, no new runtime dependency. - Structure so Sessantaquattro (Muovi-family protocol) drops in later on the same base. **Non-goals (v1)** -- A full in-GUI device-configuration panel (working mode, grid selection). Config - lives in constructor kwargs for v1. - Sessantaquattro implementation now (no test hardware available yet). - Reusing bdi at runtime / reintroducing PySide6. +In-scope (added after review): an in-GUI device-configuration panel (working +mode, gain/detection, grids, fs) — see §7. Constructor kwargs remain the +underlying config; the GUI edits the same fields. + ## 3. Architecture & file layout Self-contained `myogestic/sources/otb/` package; thin device subclasses over a @@ -122,11 +124,16 @@ need no `pyserial`/Qt/bdi, so they can ship in `sources/__init__.py` directly - **Sample format:** **big-endian, signed**; int16 (EMG) / int24 (EEG, manual sign-extend). Frame is Fortran-order `(n_channels, samples_per_frame)`: contiguous values are `[ch0_t0, ch1_t0, …, chN_t0, ch0_t1, …]`. -- **Conversion factor** (× raw → mV; same for bio & aux): - GAIN_8 = `572.2e-6`, GAIN_4 = `286.1e-6`. - ⚠️ bdi's dict and its docstrings disagree on which gain is finer; we replicate - the **dict** values to match bdi's numeric output and add a comment noting the - discrepancy. +- **Conversion factor** (× raw → mV; same for bio & aux), **physically-correct + mapping** (higher gain ⇒ finer resolution ⇒ smaller volts/LSB): + **GAIN_8 = `286.1e-6`**, **GAIN_4 = `572.2e-6`**. + ⚠️ This is the mapping in bdi's *docstrings*; bdi's *dict* has these two values + **swapped** (a latent bug). We use the correct mapping, so our µV output will + differ by 2× from the old bdi-based MyoGestic for a given gain — by being + correct. The two values are exactly 2× apart (286.1 × 2 ≈ 572.2), consistent + with the 8:4 gain ratio. OTB's communication-protocol PDF is not public + (provided on request), but the gain↔resolution relationship is unambiguous, so + no PDF is needed to settle this. A code comment will record the bdi swap. - **Aux:** 6 channels appended after biosignal (rows 32..37 / 64..69), same int width and timing, same conversion factor in bdi. - **Stop/disconnect:** resend `cfg - 1`, drain, close. @@ -207,13 +214,38 @@ gain-8; Quattrocento MEDIUM/MEDIUM, grid 0. ## 7. GUI integration -Existing `myogestic/widgets/stream_panel.py` + `_signal_scan.py` render status + -Scan/Reconnect for any source implementing `discover()`/`reconnect()` — free for -us. Wrinkle: Muovi inverts the scan model (device dials us), so Muovi -`discover()` lists local NIC IPs to bind and the panel shows "waiting for device -on :54321"; Quattrocento `discover()` does a reachability check on the device IP. -`reconnect(target)` sets the bind IP (Muovi) / device IP (Quattrocento). A full -device-config GUI panel is out of scope for v1. +**Status + scan/reconnect (free):** existing `myogestic/widgets/stream_panel.py` ++ `_signal_scan.py` render status + Scan/Reconnect for any source implementing +`discover()`/`reconnect()`. Wrinkle: Muovi inverts the scan model (device dials +us), so Muovi `discover()` lists local NIC IPs to bind and the panel shows +"waiting for device on :54321"; Quattrocento `discover()` does a reachability +check on the device IP. `reconnect(target)` sets the bind IP (Muovi) / device IP +(Quattrocento). + +**Device-config panel (in scope):** add a generic, source-agnostic config +mechanism so device settings are editable in the ImGui app — not just via +constructor kwargs. + +- **Optional Protocol extension** (mirrors the `discover()`/`reconnect()` + opt-in style): a source may expose + - `config_spec() -> list[ConfigField]` — each field has `key`, `label`, + `kind` (`enum` | `bool` | `int` | `str`), `options` (for enum), and current + `value`. + - `set_config(**kwargs) -> None` — validates and stores; takes effect on the + next `connect()`/`reconnect()`. Raises if called while connected. +- **`device_config` widget** (`myogestic/widgets/device_config.py`): renders the + spec generically — combos for enums, checkboxes for bools, input fields for + int/str — with an **Apply & Connect** button that calls `set_config(...)` then + `reconnect()`. Sources without `config_spec()` simply don't show the panel + (LSL/Replay unaffected). +- **Lifecycle:** OTB sources start **unconnected**; the acquire loop must not + auto-connect until the user applies config and connects (a per-stream + "manual connect" flag, or the source's `connect()` blocking on a + user-triggered event). This is the one acquire-loop change required; detailed + in the implementation plan. + +This keeps config source-agnostic and reusable, while constructor kwargs remain +the programmatic path (the GUI edits the same underlying fields). ## 8. Testing strategy @@ -233,15 +265,21 @@ device-config GUI panel is out of scope for v1. 1. `_base` + `_decode` + `_constants` + **MuoviSource** (Muovi & Muovi+), unit + loopback tests → hardware-validate. 2. **QuattrocentoSource** (client + CRC), unit + loopback tests → hardware-validate. -3. **SessantaquattroSource** later on the same base (Muovi-family protocol), +3. **GUI device-config** (`config_spec`/`set_config` + `device_config` widget + + manual-connect acquire-loop change), wired for both devices. +4. **SessantaquattroSource** later on the same base (Muovi-family protocol), validated when hardware available. -4. `examples/otb/` script + a short docs page. +5. `examples/otb/` script + a short docs page. ## 10. Open questions / risks - Muovi server-role vs the scan-oriented GUI model — handled via the `discover()` semantics above, but UX may want iteration after hardware testing. -- Conversion-factor gain swap in bdi — we mirror bdi's numbers; revisit if - physical-unit correctness matters for downstream analysis. +- Conversion-factor gain swap in bdi — **resolved**: we use the physically-correct + mapping (gain-8 = 286.1 nV finer, gain-4 = 572.2 nV), which differs 2× from the + old bdi-dict output. OTB's protocol PDF is not public, but physics settles it. +- Manual-connect acquire-loop change (§7) is the one cross-cutting change to + existing code; keep it minimal and behind a per-stream flag so LSL/Replay + behaviour is unchanged. - Quattrocento link-local networking (169.254.x.x) is an environment/setup concern, not a code one; document NIC setup in the examples/docs. From d9dd678d72cd0225d02fa61b237cea7af8f886f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 21:55:17 +0200 Subject: [PATCH 04/26] docs(spec): verify Muovi protocol against manufacturer PDFs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checked §5.1 against OTB Muovi TCP Protocol v2.4, SyncStation TCP Protocol v2.8, and MuoviPro User Manual v5.1: - Replace bdi's control-byte formula with the OFFICIAL layout (emg_eeg<<3 | mode<<1 | go); bdi encoded it differently. - Conversion factors confirmed (gain-8 286.1 nV, gain-4 572.2 nV) + input ranges + firmware HPF (Fsamp/190). - Socket role confirmed (PC = TCP server :54321; AP/DHCP detail; ~1400-byte TCP chunks). - Name the 6 aux channels (IMU quaternion WXYZ, buffer usage, sample counter). - Add §11 SyncStation multi-probe path (PC=client 192.168.76.1:54320, CRC-framed command strings) as a future addition on the same base. - Flag Quattrocento (§5.2) as bdi-sourced/provisional pending its OTB PDF, and note TEST-mode ramps as the first-connect endianness/byte-layout check. --- .../2026-06-03-otb-device-sources-design.md | 105 +++++++++++++----- 1 file changed, 80 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md index 974fc5f..1614098 100644 --- a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md +++ b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md @@ -97,21 +97,42 @@ need no `pyserial`/Qt/bdi, so they can ship in `sources/__init__.py` directly `t_end = mne_lsl.lsl.local_clock()` and back-date: `ts[i] = t_end - (N-1-i)/fs`, giving monotonic, `1/fs`-spaced timestamps in LSL clock units. -## 5. Wire protocol (extracted from bdi `main`) +## 5. Wire protocol + +Muovi/Muovi+ (§5.1) is verified against the **OTB Muovi probe TCP Protocol v2.4**, +**SyncStation TCP Protocol v2.8**, and **MuoviPro User Manual v5.1** (manufacturer +PDFs); the control-byte layout and conversion factors here are authoritative and +supersede bdi where they differ. Quattrocento (§5.2) is still **bdi-sourced only** +(no manufacturer PDF yet) — provisional, verify before phase 2. ### 5.1 Muovi / Muovi+ -- **Socket role:** host is **TCP server**; device dials in. Port **54321**. - Host NIC must share the device's subnet. +- **Socket role:** host (PC) is the **TCP server** on port **54321**; the Muovi + dials in as client. *Confirmed by OTB Muovi TCP Protocol v2.4 + MuoviPro manual + §8.1.5.* Two direct sub-cases: (a) **AP mode** — hold the probe button ~5 s; the + probe becomes a WiFi access point and DHCP-assigns the PC an IP, then connects + to the PC's server socket; (b) **infrastructure mode** — probe joins an existing + network (set via its web page at `192.168.14.X`), PC still server on 54321. + Data arrives in **~1400-byte TCP chunks** (not aligned to logical frames → + partial-recv buffering is mandatory). - **Handshake:** none; `accept()` then send config. Device streams only after a config byte with the acquisition LSB set. -- **Config:** single big-endian byte `cfg = (working_mode << 2) + detection_mode`. - Start = resend `cfg + 1`; stop = resend `cfg - 1`. - - `MuoviWorkingMode`: NONE=0, EEG=1, EMG=2 - - `MuoviDetectionMode`: NONE=0, MONOPOLAR_GAIN_8=1, MONOPOLAR_GAIN_4=2, - IMPEDANCE_CHECK=3, TEST=4 - - Rule: EEG + GAIN_4 is coerced to GAIN_8. - - Examples (idle): EMG+gain8 = `0x09` (stream `0x0A`); EMG+gain4 = `0x0A` - (stream `0x0B`); EEG+gain8 = `0x05` (stream `0x06`). +- **Config — OFFICIAL single CONTROL BYTE** (Muovi TCP Protocol v2.4; this + *supersedes* bdi's formula, which encodes the byte differently): + ``` + bit7..4 = 0 bit3 = EMG/EEG bits2-1 = MODE bit0 = GO/STOP + control = (emg_eeg << 3) | (mode << 1) | go + ``` + - `EMG/EEG`: **1 = EMG** (2000 Hz, firmware HPF ~10 Hz, **16-bit**), + **0 = EEG** (500 Hz, DC-coupled, **24-bit**). + - `MODE` (bits 2-1): `00` = monopolar gain 8; `01` = monopolar gain 4 + (EMG only, fw ≥ 3.2.0; else treated as `00`); `10` = impedance check; + `11` = test (ramps on all channels — ideal for validating decode/endianness). + - `GO` (bit0): `1` = start streaming; `0` = stop **and the device closes the + socket**. + - Worked bytes: EMG+gain8 stream = `0x09`, stop `0x08`; EMG+gain4 = `0x0B`; + EEG+gain8 = `0x01`. + - Note: ⚠️ bdi's `(working_mode<<2)+detection_mode (+1)` produces *different* + bytes (e.g. `0x0A` for EMG+gain8). **We follow the PDF, not bdi.** - **Geometry:** Muovi = 32 biosignal + 6 aux = 38 total; Muovi+ = 64 + 6 = 70. | Device | Mode | total | bio | aux | Fs | bytes/sample | samples/frame | frame bytes | @@ -124,18 +145,22 @@ need no `pyserial`/Qt/bdi, so they can ship in `sources/__init__.py` directly - **Sample format:** **big-endian, signed**; int16 (EMG) / int24 (EEG, manual sign-extend). Frame is Fortran-order `(n_channels, samples_per_frame)`: contiguous values are `[ch0_t0, ch1_t0, …, chN_t0, ch0_t1, …]`. -- **Conversion factor** (× raw → mV; same for bio & aux), **physically-correct - mapping** (higher gain ⇒ finer resolution ⇒ smaller volts/LSB): - **GAIN_8 = `286.1e-6`**, **GAIN_4 = `572.2e-6`**. - ⚠️ This is the mapping in bdi's *docstrings*; bdi's *dict* has these two values - **swapped** (a latent bug). We use the correct mapping, so our µV output will - differ by 2× from the old bdi-based MyoGestic for a given gain — by being - correct. The two values are exactly 2× apart (286.1 × 2 ≈ 572.2), consistent - with the 8:4 gain ratio. OTB's communication-protocol PDF is not public - (provided on request), but the gain↔resolution relationship is unambiguous, so - no PDF is needed to settle this. A code comment will record the bdi swap. +- **Conversion factor — MANUFACTURER-CONFIRMED** (Muovi TCP Protocol v2.4, × + raw → mV; same for bio & aux): gain 8 → **286.1 nV/LSB** (`286.1e-6` mV), range + **±9.375 mV**; gain 4 → **572.2 nV/LSB** (`572.2e-6` mV), range **±18.75 mV**. + Higher gain = finer resolution, as expected. + ⚠️ bdi's *dict* has these two values **swapped** (a latent bug; bdi's docstrings + are correct). We use the manufacturer values, so our µV output differs by 2× + from the old bdi-based MyoGestic for a given gain — by being correct. A code + comment will record the bdi swap. Pre-HPF analog input range ±300 mV (gain 8) / + ±600 mV (gain 4). EMG mode applies a firmware high-pass (EMA subtraction, + α=1/25 → cutoff Fsamp/190 ≈ 10.5 Hz at 2000 Hz) — EMG is not raw DC; document it. - **Aux:** 6 channels appended after biosignal (rows 32..37 / 64..69), same int - width and timing, same conversion factor in bdi. + width and timing. **Direct-mode meaning** (Muovi TCP Protocol v2.4): aux 1–4 = + IMU quaternion W/X/Y/Z, aux 5 = accessory buffer usage, aux 6 = sample counter. + Use these as `channel_names`. (Via the SyncStation the two accessory channels + are bit-packed differently — TRIG / 7-bit trigger code / buffer / counter — but + that's the SyncStation path, see §11.) - **Stop/disconnect:** resend `cfg - 1`, drain, close. ### 5.2 Quattrocento @@ -275,11 +300,41 @@ the programmatic path (the GUI edits the same underlying fields). - Muovi server-role vs the scan-oriented GUI model — handled via the `discover()` semantics above, but UX may want iteration after hardware testing. -- Conversion-factor gain swap in bdi — **resolved**: we use the physically-correct - mapping (gain-8 = 286.1 nV finer, gain-4 = 572.2 nV), which differs 2× from the - old bdi-dict output. OTB's protocol PDF is not public, but physics settles it. +- Conversion-factor gain swap — **resolved & manufacturer-confirmed** (Muovi TCP + Protocol v2.4): gain-8 = 286.1 nV (finer), gain-4 = 572.2 nV. Differs 2× from + the old bdi-dict output, which was buggy. +- **Muovi control byte — corrected to the official v2.4 layout** (§5.1); + supersedes bdi's encoding. Validate on first hardware run using TEST mode + (`MODE=11`, ramps) — monotonic decoded ramps confirm byte layout + endianness + in one shot. +- **Byte order** for the Muovi stream (big-endian, 2's-complement) is OTB + convention (matches bdi + OTB MATLAB examples) but not stated in the PDFs; + TEST-mode ramps confirm it cheaply on first connect. +- **Quattrocento details are bdi-sourced only** — the user-provided PDFs cover + Muovi/SyncStation, not Quattrocento. Treat §5.2 as provisional and verify + against the OTB Quattrocento communication-protocol PDF before/while + implementing phase 2. - Manual-connect acquire-loop change (§7) is the one cross-cutting change to existing code; keep it minimal and behind a per-stream flag so LSL/Replay behaviour is unchanged. - Quattrocento link-local networking (169.254.x.x) is an environment/setup concern, not a code one; document NIC setup in the examples/docs. + +## 11. Future: SyncStation multi-probe path (out of scope v1) + +Verified against SyncStation TCP Protocol v2.8 + MuoviPro manual. The SyncStation +is a **separate connection path** from direct single-probe connect: + +- PC is a **TCP client** to the SyncStation at fixed IP **192.168.76.1**, port + **54320** (direct single Muovi is PC-server on 54321 — different role & port). +- Commands are **framed message strings**: `START BYTE` (StartStop vs OptSettings + via MSB) + N `CONTROL BYTE`s (one per device slot; `SIZE<4:0>` field counts + them) + a terminating **CRC-8**. Each CONTROL BYTE adds a 4-bit `DEV` probe + selector above the same EMG/EEG·MODE·EN bits. +- The SyncStation multiplexes all active probes + 4 SyncStation aux + 2 accessory + channels into one stream; missing-probe samples are zero-filled. + +This aggregates up to 4 Muovi + 2 Muovi+/Sessantaquattro + 8 due+ + 2 Quattro+. +A `SyncStationSource` is a natural future addition on the same `_OTBSource` base +(client role + CRC-framed command builder), but not needed for the direct-connect +goal. Captured here so the base design leaves room for it. From e5d41a60d948fd13e00358c99b9e3f810279c33b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 21:58:26 +0200 Subject: [PATCH 05/26] docs(spec): confirm Muovi byte order + channel map from MuoviLite manual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MuoviLite User Manual v1.1 closes the last open items: - Byte order EXPLICITLY confirmed big-endian (§8.1.2) for both 16/24-bit; no longer an open question. - LSB = ADC_RANGE/2^24 = 286.1 nV confirms the gain-8 factor; 16-bit EMG keeps that LSB (range 18.75 mV). - All 38 channels (bio + 6 aux) share the active resolution (16-bit EMG / 24-bit EEG) -> decoder reads one uniform width per frame. - Channel map: 33-36 IMU quaternion WXYZ (BNO055 NDOF, 100 Hz held), 37 buffer usage + trigger, 38 sample counter; emit aux unscaled when include_aux. - Note OTB MATLAB demo code as the canonical decode reference. --- .../2026-06-03-otb-device-sources-design.md | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md index 1614098..2b93ea7 100644 --- a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md +++ b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md @@ -100,10 +100,13 @@ need no `pyserial`/Qt/bdi, so they can ship in `sources/__init__.py` directly ## 5. Wire protocol Muovi/Muovi+ (§5.1) is verified against the **OTB Muovi probe TCP Protocol v2.4**, -**SyncStation TCP Protocol v2.8**, and **MuoviPro User Manual v5.1** (manufacturer -PDFs); the control-byte layout and conversion factors here are authoritative and -supersede bdi where they differ. Quattrocento (§5.2) is still **bdi-sourced only** -(no manufacturer PDF yet) — provisional, verify before phase 2. +**SyncStation TCP Protocol v2.8**, **MuoviPro User Manual v5.1**, and **MuoviLite +User Manual v1.1** (manufacturer PDFs); the control-byte layout, conversion +factors, big-endian byte order, and channel map here are authoritative and +supersede bdi where they differ. OTB also ships **MATLAB demo code** (linked from +the manuals) as the canonical decode reference. Quattrocento (§5.2) is still +**bdi-sourced only** (no manufacturer PDF yet) — provisional, verify before +phase 2. ### 5.1 Muovi / Muovi+ - **Socket role:** host (PC) is the **TCP server** on port **54321**; the Muovi @@ -142,9 +145,14 @@ supersede bdi where they differ. Quattrocento (§5.2) is still **bdi-sourced onl | Muovi+ | EMG | 70 | 64 | 6 | 2000 | 2 (int16) | 10 | 1400 | | Muovi+ | EEG | 70 | 64 | 6 | 500 | 3 (int24) | 6 | 1260 | -- **Sample format:** **big-endian, signed**; int16 (EMG) / int24 (EEG, - manual sign-extend). Frame is Fortran-order `(n_channels, samples_per_frame)`: - contiguous values are `[ch0_t0, ch1_t0, …, chN_t0, ch0_t1, …]`. +- **Sample format:** **big-endian, signed** — *explicitly confirmed* (MuoviLite + manual §8.1.2: "the data format in both cases, 24 bit and 16 bit, is big + endian"). EMG = int16 (high 16 bits, LSB still 286.1 nV → ±9.375 mV range); + EEG = int24 (manual sign-extend). **All 38 channels — biosignal AND the 6 aux — + share the active resolution**: 16-bit in EMG, 24-bit in EEG. So the decoder + reads one uniform width per frame. Frame is Fortran-order + `(n_channels, samples_per_frame)`: contiguous values are + `[ch0_t0, ch1_t0, …, chN_t0, ch0_t1, …]`. - **Conversion factor — MANUFACTURER-CONFIRMED** (Muovi TCP Protocol v2.4, × raw → mV; same for bio & aux): gain 8 → **286.1 nV/LSB** (`286.1e-6` mV), range **±9.375 mV**; gain 4 → **572.2 nV/LSB** (`572.2e-6` mV), range **±18.75 mV**. @@ -155,12 +163,20 @@ supersede bdi where they differ. Quattrocento (§5.2) is still **bdi-sourced onl comment will record the bdi swap. Pre-HPF analog input range ±300 mV (gain 8) / ±600 mV (gain 4). EMG mode applies a firmware high-pass (EMA subtraction, α=1/25 → cutoff Fsamp/190 ≈ 10.5 Hz at 2000 Hz) — EMG is not raw DC; document it. -- **Aux:** 6 channels appended after biosignal (rows 32..37 / 64..69), same int - width and timing. **Direct-mode meaning** (Muovi TCP Protocol v2.4): aux 1–4 = - IMU quaternion W/X/Y/Z, aux 5 = accessory buffer usage, aux 6 = sample counter. - Use these as `channel_names`. (Via the SyncStation the two accessory channels - are bit-packed differently — TRIG / 7-bit trigger code / buffer / counter — but - that's the SyncStation path, see §11.) +- **Aux:** 6 channels appended after biosignal (Muovi rows 32..37 / Muovi+ + 64..69). **Direct-mode channel map** (Muovi TCP Protocol v2.4 + MuoviLite + §8.1.2): + - ch 33–36 = **IMU quaternion W / X / Y / Z** (Bosch BNO055, Fusion NDOF; + native 14-bit sign-extended to 16/24; updated at **100 Hz**, so values repeat + ~20× per quaternion at 2000 Hz — the IMU is held, not interpolated). + - ch 37 = **buffer usage + trigger state** (bit-packed); ch 38 = **sample + counter** (incrementing; gaps reveal dropped samples). + + Use these as `channel_names`. The IMU/counter are integer/status channels — the + bio conversion factor is not physically meaningful for them; when + `include_aux=True`, emit them **unscaled** (raw int) and label clearly. (Via the + SyncStation the accessory channels are bit-packed differently — TRIG / 7-bit + trigger code / buffer — see §11.) - **Stop/disconnect:** resend `cfg - 1`, drain, close. ### 5.2 Quattrocento @@ -307,9 +323,9 @@ the programmatic path (the GUI edits the same underlying fields). supersedes bdi's encoding. Validate on first hardware run using TEST mode (`MODE=11`, ramps) — monotonic decoded ramps confirm byte layout + endianness in one shot. -- **Byte order** for the Muovi stream (big-endian, 2's-complement) is OTB - convention (matches bdi + OTB MATLAB examples) but not stated in the PDFs; - TEST-mode ramps confirm it cheaply on first connect. +- **Byte order** — **resolved & explicitly confirmed**: big-endian, 2's-complement + (MuoviLite manual §8.1.2). TEST-mode ramps remain a cheap first-connect sanity + check, but this is no longer an open question. - **Quattrocento details are bdi-sourced only** — the user-provided PDFs cover Muovi/SyncStation, not Quattrocento. Treat §5.2 as provisional and verify against the OTB Quattrocento communication-protocol PDF before/while From f14b1024c4a247b69a158c307c79b3ab8c37577e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:03:19 +0200 Subject: [PATCH 06/26] docs(spec): confirm Muovi command + decode against OTB MATLAB reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checked §5.1/§11 against OTB MATLAB Read_muovi.m v3.0, Read_muoviAP.m v2.0, and CRC8.m: - Command = EMG*8 + Mode*2 + 1 and stop = Command-1 confirm the official control byte (bdi superseded); ConvFact 0.000286, tcpserver big-endian. - Mode table corrected: channel count varies by mode (NumChanVsMode=[38 22 38 38]); Mode 1 = "16-ch" in MATLAB v3.0 vs "gain 4" in PDF v2.4 -> firmware- dependent, avoid in v1. StreamInfo.n_channels must derive from (device, mode). - ch37 trigger/buffer unpacking (bit15 trigger, low 15 buffer); ch38 counter. - SyncStation framing confirmed: START=(SIZE<<1)|GO, per-device (DEV<<4)|(EMG<<3)|(Mode<<1)|EN, CRC8 trailer, stop=[0,CRC8]. - Plan to vendor the .m references into docs/reference/otb/ during build. --- .../2026-06-03-otb-device-sources-design.md | 76 +++++++++++++------ 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md index 2b93ea7..152ff6b 100644 --- a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md +++ b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md @@ -103,10 +103,13 @@ Muovi/Muovi+ (§5.1) is verified against the **OTB Muovi probe TCP Protocol v2.4 **SyncStation TCP Protocol v2.8**, **MuoviPro User Manual v5.1**, and **MuoviLite User Manual v1.1** (manufacturer PDFs); the control-byte layout, conversion factors, big-endian byte order, and channel map here are authoritative and -supersede bdi where they differ. OTB also ships **MATLAB demo code** (linked from -the manuals) as the canonical decode reference. Quattrocento (§5.2) is still -**bdi-sourced only** (no manufacturer PDF yet) — provisional, verify before -phase 2. +supersede bdi where they differ. OTB also ships **MATLAB demo code** as the +canonical decode reference — the user provided `Read_muovi.m` v3.0 (direct probe), +`Read_muoviAP.m` v2.0 (SyncStation), and `CRC8.m`. **Vendor these into +`docs/reference/otb/` during implementation** so the decode has a ground-truth +reference next to the code (manufacturer example scripts, reference-only — not +imported/shipped). Quattrocento (§5.2) is still **bdi-sourced only** (no +manufacturer PDF yet) — provisional, verify before phase 2. ### 5.1 Muovi / Muovi+ - **Socket role:** host (PC) is the **TCP server** on port **54321**; the Muovi @@ -127,15 +130,29 @@ phase 2. ``` - `EMG/EEG`: **1 = EMG** (2000 Hz, firmware HPF ~10 Hz, **16-bit**), **0 = EEG** (500 Hz, DC-coupled, **24-bit**). - - `MODE` (bits 2-1): `00` = monopolar gain 8; `01` = monopolar gain 4 - (EMG only, fw ≥ 3.2.0; else treated as `00`); `10` = impedance check; - `11` = test (ramps on all channels — ideal for validating decode/endianness). + - `MODE` (bits 2-1) — **the channel count and meaning depend on mode** (and on + firmware/version). Per OTB's own MATLAB `Read_muovi.m` v3.0 + (`NumChanVsMode = [38 22 38 38]`): + - `00` = monopolar **32-ch** (gain 8) → **38 ch** (32 bio + 6 aux). *Default.* + - `01` = monopolar **16-ch** → **22 ch** (16 bio + 6 aux). ⚠️ Ambiguous: the + v2.4 PDF labels `01` as "gain 4, 32 ch", while MATLAB v3.0 labels it + "16-ch monop". Treat as firmware-dependent; **avoid in v1** unless we confirm + on the specific probe firmware. (Muovi+ analogue: `0`=64-ch→68, `1`=32-ch→36.) + - `10` = impedance check → 38 ch. + - `11` = test (ramps on all channels) → 38 ch. Ideal for validating + decode/endianness on first connect. - `GO` (bit0): `1` = start streaming; `0` = stop **and the device closes the socket**. - - Worked bytes: EMG+gain8 stream = `0x09`, stop `0x08`; EMG+gain4 = `0x0B`; - EEG+gain8 = `0x01`. + - **Confirmed by OTB MATLAB** `Read_muovi.m`: `Command = EMG*8 + Mode*2 + 1` + (= our formula), `ConvFact = 0.000286`, `tcpserver(...,"ByteOrder","big-endian")`, + stop = `Command - 1`. + - Worked bytes: EMG+mode0 stream = `0x09`, stop `0x08`; EMG+mode1 = `0x0B`; + EEG+mode0 = `0x01`. - Note: ⚠️ bdi's `(working_mode<<2)+detection_mode (+1)` produces *different* - bytes (e.g. `0x0A` for EMG+gain8). **We follow the PDF, not bdi.** + bytes (e.g. `0x0A` for EMG+mode0). **We follow the PDF + OTB MATLAB, not bdi.** + - **Implication for the source:** `StreamInfo.n_channels` must be derived from + `(device, mode)`, not hardcoded — mode 1 changes the count. The decoder reads + `NumChan` channels per sample-instant where `NumChan` = the table above. - **Geometry:** Muovi = 32 biosignal + 6 aux = 38 total; Muovi+ = 64 + 6 = 70. | Device | Mode | total | bio | aux | Fs | bytes/sample | samples/frame | frame bytes | @@ -169,11 +186,16 @@ phase 2. - ch 33–36 = **IMU quaternion W / X / Y / Z** (Bosch BNO055, Fusion NDOF; native 14-bit sign-extended to 16/24; updated at **100 Hz**, so values repeat ~20× per quaternion at 2000 Hz — the IMU is held, not interpolated). - - ch 37 = **buffer usage + trigger state** (bit-packed); ch 38 = **sample - counter** (incrementing; gaps reveal dropped samples). - - Use these as `channel_names`. The IMU/counter are integer/status channels — the - bio conversion factor is not physically meaningful for them; when + - ch 37 = **buffer usage + trigger state** (bit-packed). Unpacking confirmed by + OTB `Read_muovi.m`: in 16-bit mode, **bit 15 = trigger** (value < 0 ⇒ trigger + high), **lower 15 bits = buffer usage** (`buffer = value + 32768` when the sign + bit is set). In 24-bit mode the same fields sit in the top/low bits of the + 24-bit word. + - ch 38 = **sample counter / ramp** (incrementing; successive deltas reveal + dropped samples). + + Use these as `channel_names`. The IMU/counter/trigger are integer/status + channels — the bio conversion factor is not physically meaningful for them; when `include_aux=True`, emit them **unscaled** (raw int) and label clearly. (Via the SyncStation the accessory channels are bit-packed differently — TRIG / 7-bit trigger code / buffer — see §11.) @@ -338,17 +360,25 @@ the programmatic path (the GUI edits the same underlying fields). ## 11. Future: SyncStation multi-probe path (out of scope v1) -Verified against SyncStation TCP Protocol v2.8 + MuoviPro manual. The SyncStation -is a **separate connection path** from direct single-probe connect: +Verified against SyncStation TCP Protocol v2.8 + MuoviPro manual + OTB MATLAB +`Read_muoviAP.m` v2.0. The SyncStation is a **separate connection path** from +direct single-probe connect: - PC is a **TCP client** to the SyncStation at fixed IP **192.168.76.1**, port **54320** (direct single Muovi is PC-server on 54321 — different role & port). -- Commands are **framed message strings**: `START BYTE` (StartStop vs OptSettings - via MSB) + N `CONTROL BYTE`s (one per device slot; `SIZE<4:0>` field counts - them) + a terminating **CRC-8**. Each CONTROL BYTE adds a 4-bit `DEV` probe - selector above the same EMG/EEG·MODE·EN bits. -- The SyncStation multiplexes all active probes + 4 SyncStation aux + 2 accessory - channels into one stream; missing-probe samples are zero-filled. +- Commands are **framed message strings** (confirmed by `Read_muoviAP.m`): + - `START BYTE A = (SIZE << 1) | GO` where `SIZE` = number of CONTROL BYTEs + (1–16) and bit6 = `REC_ON`; MSB=0 ⇒ StartStop, MSB=1 ⇒ OptSettings. + - One `CONTROL BYTE` per enabled device = `(DEV << 4) | (EMG << 3) | (Mode << 1) + | EN`, with device offsets `DEV<<4` = 0/16/32/48 (Muovi 1–4), 64/80 + (Muovi+/Sessantaquattro 1–2). + - Trailing **CRC-8** over all preceding bytes (poly `0x8C`, see `CRC8.m`). + - Stop = `[0x00, CRC8([0x00])]`. +- Channel accounting (from `Read_muoviAP.m`): stream starts with **6 SyncStation + aux/accessory** channels, then per device `NumChanVsMode` — Muovi `[38 22 38 38]`, + Muovi+/Sessantaquattro `[68 36 68 68]` (note: differs from direct Muovi+ 70 — the + SyncStation path needs its own channel-map verification). int16, big-endian, + Fortran `reshape(NumChan, fs)`. Missing-probe samples are zero-filled. This aggregates up to 4 Muovi + 2 Muovi+/Sessantaquattro + 8 due+ + 2 Quattro+. A `SyncStationSource` is a natural future addition on the same `_OTBSource` base From f37b209aa8599157aaba5030533e0ce981f94200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:05:31 +0200 Subject: [PATCH 07/26] docs(reference): vendor OTB MATLAB decode references for OTB sources Reference-only (not imported/shipped) manufacturer example scripts that the native OTB sources will mirror: Read_muovi.m (direct probe), Read_muoviAP.m (SyncStation), CRC8.m, plus a README distilling the verified protocol facts. --- docs/reference/otb/CRC8.m | 30 ++++ docs/reference/otb/README.md | 30 ++++ docs/reference/otb/Read_muovi.m | 267 ++++++++++++++++++++++++++++++ docs/reference/otb/Read_muoviAP.m | 210 +++++++++++++++++++++++ 4 files changed, 537 insertions(+) create mode 100644 docs/reference/otb/CRC8.m create mode 100644 docs/reference/otb/README.md create mode 100644 docs/reference/otb/Read_muovi.m create mode 100644 docs/reference/otb/Read_muoviAP.m diff --git a/docs/reference/otb/CRC8.m b/docs/reference/otb/CRC8.m new file mode 100644 index 0000000..9c08b19 --- /dev/null +++ b/docs/reference/otb/CRC8.m @@ -0,0 +1,30 @@ +function crc = CRC8(Vector, Len) + +crc = 0; +j = 1; + +while(Len > 0) + Extract = Vector(j); + for i = 8:-1:1 + + Sum = xor(mod(crc,2), mod(Extract,2)); + crc = floor(crc/2); + + if(Sum > 0) + str = zeros(1,8); + a = dec2bin(crc,8); + b = dec2bin(140,8); + for k = 1 : 8 + str(k) = ~((a(k) == b(k))); + end + + crc = bin2dec(strrep(num2str(str),' ','')); + end + + Extract = floor(Extract/2); + end + + Len = Len - 1; + + j=j+1; +end \ No newline at end of file diff --git a/docs/reference/otb/README.md b/docs/reference/otb/README.md new file mode 100644 index 0000000..ac098a3 --- /dev/null +++ b/docs/reference/otb/README.md @@ -0,0 +1,30 @@ +# OT Bioelettronica reference code & protocol notes + +Manufacturer reference material for the native OTB device sources +(`myogestic/sources/otb/`). **Reference only — not imported or shipped.** These +are OT Bioelettronica's own example scripts, kept here as the ground-truth decode +the Python implementation mirrors. + +| File | Source | Role | +|------|--------|------| +| `Read_muovi.m` | OTB MATLAB example v3.0 | Direct single-Muovi connection (PC = TCP server :54321). Canonical control-byte + decode reference. | +| `Read_muoviAP.m` | OTB MATLAB example v2.0 | SyncStation path (PC = TCP client 192.168.76.1:54320), CRC8-framed multi-probe command strings. | +| `CRC8.m` | OTB MATLAB example | CRC-8 (poly `0x8C`, LSB-first, init 0) used by the SyncStation / Quattrocento framed commands. | + +Protocol verified against (manufacturer PDFs, not committed): +- Muovi probe TCP Communication Protocol v2.4 +- SyncStation TCP Communication Protocol v2.8 +- MuoviPro User Manual v5.1 +- MuoviLite User Manual v1.1 + +Design spec: `docs/superpowers/specs/2026-06-03-otb-device-sources-design.md`. + +Key facts distilled from these references: +- Direct Muovi: PC is **TCP server** on **54321**; control byte + `(EMG<<3) | (Mode<<1) | GO`; stop = `byte - 1`. +- Data is **big-endian**, 2's-complement; EMG = int16 (2000 Hz), EEG = int24 + (500 Hz). Conversion factor (gain 8) = **286.1 nV/LSB** (`0.000286` mV). +- Frame is Fortran-order `(n_channels, samples)`; `n_channels` depends on + `(device, mode)` — Muovi `NumChanVsMode = [38 22 38 38]`. +- Channel map: 1–32 bio, 33–36 IMU quaternion W/X/Y/Z, 37 buffer+trigger, + 38 sample counter. diff --git a/docs/reference/otb/Read_muovi.m b/docs/reference/otb/Read_muovi.m new file mode 100644 index 0000000..7a9accd --- /dev/null +++ b/docs/reference/otb/Read_muovi.m @@ -0,0 +1,267 @@ +% Example script for direct communication with Muovi +% +% This script builds the communication command starting from the values +% of few variables, open a socket for the connection of Muovi and, when the +% Muovi is connected, sends the command and starts receiving data +% +% OT Bioelettronica +% v. 3.0 + +close all +clear all + +% Initialization +TCPPort = 54321; % Number of TCP socket port +NumCycles = 20; % Number of recording cycles +OffsetEMG = 1; % Offset between the channels' signals +PlotTime = 1; +OnlyEMG = 0; + +% ------------------------------------------------------------------------- +% Refer to the communication protocol for details about these variables: +ProbeEN = 1; % 1 = Probe enabled, 0 = probe disabled +EMG = 1; % 1 = EMG, 0 = EEG +Mode = 0; % 0 = 32Ch Monop, 1 = 16Ch Monop, 2 = 32Ch ImpCk, 3 = 32Ch Test + +% Conversion factor for the bioelectrical signals to get the values in mV +ConvFact = 0.000286; + +% Number of acquired channel depending on the acquisition mode +NumChanVsMode = [38 22 38 38]; + +% Prevents error of the user in the variables' initialization +if ProbeEN > 1 + disp("Error, set ProbeEN values equal to 0 or 1") + return; +end + +if EMG > 1 + disp("Error, set EMGX values equal to 0 or 1") + return; +end + +if Mode > 7 + disp("Error, set ModeX values between 0 and 7") + return; +end + +% Create the command to send to Muovi +Command = 0; +if ProbeEN == 1 + Command = 0 + EMG * 8 + Mode * 2 + 1; + NumChan = NumChanVsMode(Mode+1); + if EMG == 0 + sampFreq = 500; % Sampling frequency = 500 for EEG + else + sampFreq = 2000; % Sampling frequency = 2000 for EMG + end +end + +% Conversion from decimal integer to its binary representation +dec2bin(Command) + +% The function tcpip is substituted by the functions +% tcpserver/tcpclient from Matlab version 2022a + +if verLessThan('matlab','9.12') + % Open the TCP socket as server + t = tcpip('0.0.0.0', TCPPort, 'NetworkRole', 'server'); + % Increase the input buffer size + t.InputBufferSize = 500000; %190152; + % Wait into this function until a client is connected + fopen(t) +else + % Open the TCP socket as server + t = tcpserver(TCPPort,"ByteOrder","big-endian"); + % Increase the input buffer size + t.InputBufferSize = 500000; %190152; + % Wait into this function until a client is connected + fopen(t) + while(t.Connected < 1) + pause(0.1) + end +end + +disp('Connected to the Socket') + +if OnlyEMG == 1 + EMG = plot(0); + xlim([0 sampFreq]) + ylim([-OffsetEMG (OffsetEMG*(NumChan-6))]) +else + subplot(3,1,1) + EMG = plot(0); + xlim([0 sampFreq]) + ylim([-OffsetEMG (OffsetEMG*(NumChan-6))]) + + subplot(3,1,2) + IMU = plot(0); + xlim([0 sampFreq]) + ylim([-33000 33000]) + + subplot(3,3,7) + Buf = plot(0); + xlim([0 sampFreq]) + ylim([0 16000]) + + subplot(3,3,8) + Trig = plot(0); + xlim([0 sampFreq]) + ylim([0 1.1]) + + subplot(3,3,9) + Ramp = plot(0); + xlim([0 sampFreq]) + ylim([-33000 33000]) +end + +% Send the command to Muovi +fwrite(t, Command, 'uint8'); + +if ProbeEN == 0 + % Wait to be sure the command is received before closing the socket + pause(0.5); + clear t; +end + +% If the high resolution mode (24 bits) is active +if(EMG == 0) + % One second of data: 3 bytes * channels * Sampling frequency + blockData = 3*NumChan*sampFreq; + + ChInd = (1:3:NumChan*3); + + % Main plot loop + for i = 1 : NumCycles + + i + + % Wait here until one second of data has been received + while(t.BytesAvailable < blockData) + end + + % Read one second of data into single bytes + Temp = fread(t, NumChan*3*sampFreq, 'uint8'); + Temp1 = reshape(Temp, NumChan*3, sampFreq); + + % Combine 3 bytes to a 24 bit value + data{i} = Temp1(ChInd,:)*65536 + Temp1(ChInd+1,:)*256 + Temp1(ChInd+2,:); + + % Search for the negative values and make the two's complement + ind = find(data{i} >= 8388608); + data{i}(ind) = data{i}(ind) - (16777216); + + % Plot the EEG signals + subplot(3,1,1) + % Plot the data received + hold off + for j = 1 : NumChan-6 + plot(data{i}(j,:)*ConvFact + 0.1*(j-1)); + hold on + end + + % Plot the IMU + subplot(3,1,2) + hold off + for j = NumChan-5 : NumChan-2 + %set(IMU, 'YData', data{i}(j,:)); + plot(data{i}(j,:)) + hold on + end + +% subplot(2,1,2) +% plot(rem((data{i}(NumChan-1,:)), 16384)*8); +% drawnow; + + ind = find(data{i}(NumChan-1,:) < 0); + Trigger = zeros(sampFreq, 1); + Trigger(ind) = 1; + Buffer = data{i}(NumChan-1,:); + Buffer(ind) = data{i}(NumChan-1,ind) + 32768; + + subplot(3,3,7) + set(Buf, 'YData', Buffer); + + subplot(3,3,8) + set(Trig, 'YData', Trigger); + + subplot(3,3,9) + set(Ramp, 'YData', data{i}(NumChan,:)); + + drawnow; + end +else + + % If the low resolution mode (16 bits) is active + + % One second of data: 2 bytes * channels * Sampling frequency + blockData = 2*NumChan*sampFreq; + + % Main plot loop + for i = 1 : NumCycles + + i + + % Wait here until one second of data has been received + while(t.BytesAvailable < blockData) + end + + % Read one second of data into signed integer + Temp = fread(t, NumChan*sampFreq, 'int16'); + data{i} = reshape(Temp, NumChan, sampFreq); + + if OnlyEMG == 1 + hold off + for j = 1 : NumChan-6 + %set(EMG, 'YData', data{i}(j,:)*ConvFact + OffsetEMG*(j-1)); + plot(data{i}(j,:)*ConvFact + OffsetEMG*(j-1)) + hold on + end + else + % Plot the EMG signals + subplot(3,1,1) + hold off + for j = 1 : NumChan-6 + %set(EMG, 'YData', data{i}(j,:)*ConvFact + OffsetEMG*(j-1)); + plot(data{i}(j,:)*ConvFact + OffsetEMG*(j-1)) + hold on + end + + % Plot the IMU + subplot(3,1,2) + hold off + for j = NumChan-5 : NumChan-2 + %set(IMU, 'YData', data{i}(j,:)); + plot(data{i}(j,:)) + hold on + end + + ind = find(data{i}(NumChan-1,:) < 0); + Trigger = zeros(sampFreq, 1); + Trigger(ind) = 1; + Buffer = data{i}(NumChan-1,:); + Buffer(ind) = data{i}(NumChan-1,ind) + 32768; + + subplot(3,3,7) + set(Buf, 'YData', Buffer); + + subplot(3,3,8) + set(Trig, 'YData', Trigger); + + subplot(3,3,9) + set(Ramp, 'YData', data{i}(NumChan,:)); + end + + drawnow; + end +end + +% Stop the data transfer +fwrite(t, Command-1, 'uint8'); + +% Wait to be sure the command is received before closing the socket +pause(0.5); + +% Close the TCP socket +clear t + diff --git a/docs/reference/otb/Read_muoviAP.m b/docs/reference/otb/Read_muoviAP.m new file mode 100644 index 0000000..f497321 --- /dev/null +++ b/docs/reference/otb/Read_muoviAP.m @@ -0,0 +1,210 @@ +% Example script for communication with Muovi/Muovi+ through SyncStation +% +% This script builds the communication command starting from the values +% of few variables, open a socket for the connection with SyncStation and, +% when the Sync is connected, sends the command and starts receiving data +% +% OT Bioelettronica +% v. 2.0 + +close all +clear all + +TCPPort = 54320; % Number of TCP socket port +NumCycles = 10; % Number of recording cycles +OffsetEMG = 1; % Offset between the channels' signals +PlotTime = 1; + +% ---------- Muovi 1 ------------------------------------------------------ +Muovi1EN = 0; % 1 = Muovi enabled, 0 = Muovi disabled +EMG1 = 1; % 1 = EMG, 0 = EEG +Mode1 = 3; % 0 = 32Ch Monop, 1 = 16Ch Monop, 2 = 32Ch ImpCk, 3 = 32Ch Test +% ---------- Muovi 2 ------------------------------------------------------ +Muovi2EN = 0; % 1 = Muovi enabled, 0 = Muovi disabled +EMG2 = 1; % 1 = EMG, 0 = EEG +Mode2 = 3; % 0 = 32Ch Monop, 1 = 16Ch Monop, 2 = 32Ch ImpCk, 3 = 32Ch Test +% ---------- Muovi 3 ------------------------------------------------------ +Muovi3EN = 1; % 1 = Muovi enabled, 0 = Muovi disabled +EMG3 = 1; % 1 = EMG, 0 = EEG +Mode3 = 3; % 0 = 32Ch Monop, 1 = 16Ch Monop, 2 = 32Ch ImpCk, 3 = 32Ch Test +% ---------- Muovi 4 ------------------------------------------------------ +Muovi4EN = 0; % 1 = Muovi enabled, 0 = Muovi disabled +EMG4 = 1; % 1 = EMG, 0 = EEG +Mode4 = 3; % 0 = 32Ch Monop, 1 = 16Ch Monop, 2 = 32Ch ImpCk, 3 = 32Ch Test +% ---------- Muovi+ 1/ Sessantaquattro+ 1 -------------------------------------------- +SessnP5EN = 0; % 1 = Muovi+/ Sessantaquattro+ enabled, 0 = Sessantaquattro+ disabled +EMG5 = 1; % 1 = EMG, 0=EEG +Mode5 = 3; % 0 = 64Ch Monop, 1 = 32Ch Monop, 2 = 64Ch ImpCk, 3 = 64Ch Test +% ---------- Muovi+ 2/ Sessantaquattro+ 2 -------------------------------------------- +SessnP6EN = 0; % 1 = Muovi+/ Sessantaquattro+ enabled, 0 = Muovi disabled +EMG6 = 1; % 1 = EMG, 0 = EEG +Mode6 = 1; % 0 = 64Ch Monop, 1 = 32Ch Monop, 2 = 64Ch ImpCk, 3 = 64Ch Test + +% Number of acquired channel depending on the acquisition mode +NumChanVsModeMuovi = [38 22 38 38]; +NumChanVsModeSessn = [68 36 68 68]; + +% Prevents error of the user in the variables' initialization +if Muovi1EN > 1 | Muovi2EN > 1 | Muovi3EN > 1 | Muovi4EN > 1 | SessnP5EN > 1 | SessnP6EN > 1 + disp("Error, set MuoviXEN values equal to 0 or 1") + return; +end + +if EMG1 > 1 | EMG2 > 1 | EMG3 > 1 | EMG4 > 1 | EMG5 > 1 | EMG6 > 1 + disp("Error, set EMGX values equal to 0 or 1") + return; +end + +if Mode1 > 3 | Mode2 > 3 | Mode3 > 3 | Mode4 > 3 | Mode5 > 3 | Mode6 > 3 + disp("Error, set ModeX values between to 0 and 7") + return; +end + +SizeComm = (Muovi1EN + Muovi2EN + Muovi3EN + Muovi4EN + SessnP5EN + SessnP6EN)*2; +ConfString(1) = SizeComm + 1; %GO + +% Create the command to send to Muovi/Muovi+/Sessantaquattro+ +sampFreq = 500; +NumChan = 6; +j = 2; +if Muovi1EN == 1 + ConfString(j) = 0 + EMG1 * 8 + Mode1 * 2 + 1; + NumChan = NumChan + NumChanVsModeMuovi(Mode1+1); + if EMG1 == 1 + sampFreq = 2000; + end + j = j+1; +end + +if Muovi2EN == 1 + ConfString(j) = 16 + EMG2 * 8 + Mode2 * 2 + 1; + NumChan = NumChan + NumChanVsModeMuovi(Mode2+1); + if EMG2 == 1 + sampFreq = 2000; + end + j = j+1; +end + +if Muovi3EN == 1 + ConfString(j) = 32 + EMG3 * 8 + Mode3 * 2 + 1; + NumChan = NumChan + NumChanVsModeMuovi(Mode3+1); + if EMG3 == 1 + sampFreq = 2000; + end + j = j+1; +end + +if Muovi4EN == 1 + ConfString(j) = 48 + EMG4 * 8 + Mode4 * 2 + 1; + NumChan = NumChan + NumChanVsModeMuovi(Mode4+1); + if EMG4 == 1 + sampFreq = 2000; + end + j = j+1; +end + +if SessnP5EN == 1 + ConfString(j) = 64 + EMG5 * 8 + Mode5 * 2 + 1; + NumChan = NumChan + NumChanVsModeSessn(Mode5+1); + if EMG5 == 1 + sampFreq = 2000; + end + j = j+1; +end + +if SessnP6EN == 1 + ConfString(j) = 80 + EMG6 * 8 + Mode6 * 2 + 1; + NumChan = NumChan + NumChanVsModeSessn(Mode6+1); + if EMG6 == 1 + sampFreq = 2000; + end + j = j+1; +end + +ConfString(j) = CRC8(ConfString, j-1); + +% The function tcpip is substituted by the functions +% tcpserver/tcpclient from Matlab version 2022a + +% Open the TCP socket +if verLessThan('matlab','9.12') + tcpSocket = tcpip('192.168.76.1', TCPPort, 'NetworkRole', 'client'); + % Increase the input buffer size + tcpSocket.InputBufferSize = NumChan * sampFreq * 2; % 2 s of data with all ch @ 2kHz + % Wait into this function until a server is connected + fopen(tcpSocket); +else + % Open the TCP socket as client + tcpSocket = tcpclient('192.168.76.1',TCPPort); + + % % Wait into this function until a server is connected + % while(tcpSocket.Connected < 1) + % pause(0.1) + % end +end + + +% One second of data: 2 bytes * channels * Sampling frequency +blockData = 2*NumChan*sampFreq*PlotTime; + +% Send the configuration to Muovi station +fwrite(tcpSocket, ConfString, 'uint8'); + +for i = 1 : NumCycles + i + + % Wait here until one second of data has been received + while(tcpSocket.BytesAvailable < blockData) + end + + if verLessThan('matlab','9.12') + % Read one second of data into signed integer + data = fread(tcpSocket, [NumChan, sampFreq*PlotTime], 'int16'); + else + % Read one second of data into signed integer + Temp = read(tcpSocket, NumChan*sampFreq, 'int16'); + data = reshape(Temp, NumChan, sampFreq); + end + + if NumChan > 12 + subplot(3,1,1) + hold off + for j = 1 : NumChan - 12 + plot(data(j,:)) % + OffsetEMG*(j-1)) + hold on + end + + subplot(3,1,2) + hold off + for j = NumChan - 11 : NumChan - 6 + plot(data(j,:)) % + OffsetEMG*(j-1)) + hold on + end + + subplot(3,1,3) + hold off + for j = NumChan - 5 : NumChan + plot(data(j,:)) + hold on + end + else + hold off + for j = 1 : NumChan + plot(data(j,:)) + hold on + end + end + + drawnow +end + +clear ConfString; +ConfString(1) = 0; +ConfString(2) = CRC8(ConfString, 1); + +% Send the configuration to muovi station +fwrite(tcpSocket, ConfString, 'uint8'); + +clear tcpSocket + + From 49f51e1f11e3466ad8d5056fcf600aa3e9ed9918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:10:47 +0200 Subject: [PATCH 08/26] docs(spec): verify Quattrocento protocol against manufacturer docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checked §5.2 against Quattrocento Configuration Protocol v1.7 and OTB MATLAB Read_Quattrocento.m v5.0 (+ CRC8.m, identical poly 0x8C): - ACQ_SETT bit layout confirmed; per-input 3-byte config (CONF0 muscle / CONF1 sensor+adapter / CONF2 side|hpf|lpf|mode) with 0-based bit values (mode 00=mono/01=diff/10=bipolar; default CONF2=0x14). - TCP client 169.254.1.10:23456, little-endian int16 confirmed. - Channel counts 120/216/312/408; accessory channels are the last 8 (counter @ n-7, trigger @ n-6, buffer @ n-4); conversion factors confirmed (bio 5.086e-4 mV, AUX IN 1.526e-4 V, accessory raw). - Stop = byte0 0x80 + recomputed CRC (always stop before reconnect). - §5.2/§10 promoted from provisional to manufacturer-verified. - Vendor Read_Quattrocento.m, FourMapsQuattrocento.m, ReadFromOTBiolabLightQuattrocento.m into docs/reference/otb/. --- docs/reference/otb/FourMapsQuattrocento.m | 321 ++++++++++++++++++ docs/reference/otb/README.md | 15 + .../otb/ReadFromOTBiolabLightQuattrocento.m | 57 ++++ docs/reference/otb/Read_Quattrocento.m | 224 ++++++++++++ .../2026-06-03-otb-device-sources-design.md | 94 +++-- 5 files changed, 674 insertions(+), 37 deletions(-) create mode 100644 docs/reference/otb/FourMapsQuattrocento.m create mode 100644 docs/reference/otb/ReadFromOTBiolabLightQuattrocento.m create mode 100644 docs/reference/otb/Read_Quattrocento.m diff --git a/docs/reference/otb/FourMapsQuattrocento.m b/docs/reference/otb/FourMapsQuattrocento.m new file mode 100644 index 0000000..567acce --- /dev/null +++ b/docs/reference/otb/FourMapsQuattrocento.m @@ -0,0 +1,321 @@ +% Routine for the real time plot of four maps. +% Details: +% - Direct communication with Quattrocento. This Matlab scripts doesn't need +% OT BioLab, direcly communicate with the TCP socket opened on the +% Quattrocento. It opens a TCP socket, set the configuration and reads back +% data at the desired sampling frequency. +% - Maps plotted from the signals coming from the four Multiple Inputs +% - Written for the ELSCH064NM2 +% +% OT Bioelettronica +% March 20th 2017 +% v 1.0 + + +close all + +TestDuration = 10; % Total duration of the test in seconds +MapsARVEpoch = 0.25; % Time epoch for the ARV estimation in seconds (must be multiple of RefreshRate) +RefreshRate = 0.125; % Maps refresh rate in seconds +ColorScale = 1; % Set the amplitude in mV corresponding to the white color (max range) in the color plots + +%---------------------------------------- +% 64 electrode matrix mapping by column (SD) +col{1} = [4,5,11,10,24,32,34,39,40,49,50,62,61]; % column no.1 +col{2} = [3,6,12,9,23,31,33,38,48,41,51,63,60]; % column no.2 +col{3} = [2,7,13,17,22,30,27,37,47,42,52,64,59]; % column no.3 +col{4} = [1,8,14,18,21,29,26,36,46,43,53,56,58]; % column no.4 +col{5} = [1,16,15,19,20,28,25,35,45,44,54,55,57]; % column no.5 +n_column = 5; +n_row = 12; +%---------------------------------------- + +NumRefreshPerEpoch = MapsARVEpoch/RefreshRate; +NumCycles = TestDuration/RefreshRate; + +% Sampling frequency values +Fsamp = [0 8 16 24]; % Codes to set the sampling frequency +FsampVal = [512 2048 5120 10240]; +FSsel = 2; +% FSsel = 1 -> 512 Hz +% FSsel = 2 -> 2048 Hz +% FSsel = 3 -> 5120 Hz +% FSsel = 4 -> 10240 Hz + +% Channels numbers +NumChan = [0 2 4 6]; % Codes to set the number of channels +NumChanVal = [120 216 312 408]; +NCHsel = 4; +% NCHsel = 1 -> IN1, IN2, MULTIPLE IN1, AUX IN +% NCHsel = 2 -> IN1..IN4, MULTIPLE IN1, MULTIPLE IN2, AUX IN +% NCHsel = 3 -> IN1..IN6, MULTIPLE IN1..MULTIPLE IN3, AUX IN +% NCHsel = 4 -> IN1..IN8, MULTIPLE IN1..MULTIPLE IN4, AUX IN + +AnOutSource = 0; % Source input for analog output: +% 0 = the analog output signal came from IN1 +% 1 = the analog output signal came from IN2 +% 2 = the analog output signal came from IN3 +% 3 = the analog output signal came from IN4 +% 4 = the analog output signal came from IN5 +% 5 = the analog output signal came from IN6 +% 6 = the analog output signal came from IN7 +% 7 = the analog output signal came from IN8 +% 8 = the analog output signal came from MULTIPLE IN1 +% 9 = the analog output signal came from MULTIPLE IN2 +% 10 = the analog output signal came from MULTIPLE IN3 +% 11 = the analog output signal came from MULTIPLE IN4 +% 12 = the analog output signal came from AUX IN +AnOutChan = 0; % Channel for analog output +AnOutGain = bin2dec('00010000'); +% bin2dec('00000000') = Gain on the Analog output equal to 1 +% bin2dec('00010000') = Gain on the Analog output equal to 2 +% bin2dec('00100000') = Gain on the Analog output equal to 4 +% bin2dec('00110000') = Gain on the Analog output equal to 16 + +% Provide amplitude in mV +GainFactor = 5/2^16/150*1000; +% 5 is the ADC input swing +% 2^16 is the resolution +% 150 is the gain +% 1000 to get the mV + +% Generate the configuration string +ConfString(1) = bin2dec('10000000') + Fsamp(FSsel) + NumChan(NCHsel) + 1; +ConfString(2) = AnOutGain + AnOutSource; +ConfString(3) = AnOutChan; +% -------- IN 1 -------- % +ConfString(4) = 0; +ConfString(5) = 0; +ConfString(6) = bin2dec('00010100'); +% -------- IN 2 -------- % +ConfString(7) = 0; +ConfString(8) = 0; +ConfString(9) = bin2dec('00010100'); +% -------- IN 3 -------- % +ConfString(10) = 0; +ConfString(11) = 0; +ConfString(12) = bin2dec('00010100'); +% -------- IN 4 -------- % +ConfString(13) = 0; +ConfString(14) = 0; +ConfString(15) = bin2dec('00010100'); +% -------- IN 5 -------- % +ConfString(16) = 0; +ConfString(17) = 0; +ConfString(18) = bin2dec('00010100'); +% -------- IN 6 -------- % +ConfString(19) = 0; +ConfString(20) = 0; +ConfString(21) = bin2dec('00010100'); +% -------- IN 7 -------- % +ConfString(22) = 0; +ConfString(23) = 0; +ConfString(24) = bin2dec('00010100'); +% -------- IN 8 -------- % +ConfString(25) = 0; +ConfString(26) = 0; +ConfString(27) = bin2dec('00010100'); +% -------- MULTIPLE IN 1 -------- % +ConfString(28) = 0; +ConfString(29) = 0; +ConfString(30) = bin2dec('00010100'); +% -------- MULTIPLE IN 2 -------- % +ConfString(31) = 0; +ConfString(32) = 0; +ConfString(33) = bin2dec('00010100'); +% -------- MULTIPLE IN 3 -------- % +ConfString(34) = 0; +ConfString(35) = 0; +ConfString(36) = bin2dec('00010100'); +% -------- MULTIPLE IN 4 -------- % +ConfString(37) = 0; +ConfString(38) = 0; +ConfString(39) = bin2dec('00010100'); +% ---------- CRC8 ---------- % +ConfString(40) = CRC8(ConfString, 39); + +% Accessory channels +RampChan = NumChanVal(NCHsel)-7; +BuffChan = NumChanVal(NCHsel)-4; + +% Number of samples for each read corresponding to the number of samples of the +% refresh rate +NumSampBlockRead = FsampVal(FSsel)*RefreshRate; +EpochSegment = 1; + +DiffARVMatr1 = zeros(n_column,n_row,NumRefreshPerEpoch); +DiffARVMatr2 = zeros(n_column,n_row,NumRefreshPerEpoch); +DiffARVMatr3 = zeros(n_column,n_row,NumRefreshPerEpoch); +DiffARVMatr4 = zeros(n_column,n_row,NumRefreshPerEpoch); +DiffARVMatrOverEpoch1 = zeros(n_column,n_row); +DiffARVMatrOverEpoch2 = zeros(n_column,n_row); +DiffARVMatrOverEpoch3 = zeros(n_column,n_row); +DiffARVMatrOverEpoch4 = zeros(n_column,n_row); + +% PC's active screen size +screen_size = get(0,'ScreenSize'); +pc_width = screen_size(3); +pc_height = screen_size(4); + +% Matlab also does not consider the height of the figure's toolbar... +% Or the width of the border... they only care about the contents! +toolbar_height = 77; +window_border = 5; + +% The Format of Matlab is this: +% [left, bottom, width, height] +m_left = pc_width/2 - pc_height/4; +m_bottom = 0; +m_height = pc_height-toolbar_height-1; +m_width = pc_height/2; + +h = figure; + +% Set the correct position of the figure +set(h, 'Position', [m_left, m_bottom, m_width, m_height]); + +gcf; + +colormap hot; +subplot(2,2,1) +h1 = surf(DiffARVMatrOverEpoch1); view(270,90); +ylim ([1,5]); +xlim ([1,12]); +caxis([1 ColorScale/GainFactor]) + +subplot(2,2,2) +h2 = surf(DiffARVMatrOverEpoch2); view(270,90); +shading interp; +ylim ([1,5]); +xlim ([1,12]); +caxis([1 ColorScale/GainFactor]) + +subplot(2,2,3) +h3 = surf(DiffARVMatrOverEpoch3); view(270,90); +shading interp; +ylim ([1,5]); +xlim ([1,12]); +caxis([1 ColorScale/GainFactor]) + +subplot(2,2,4) +h4 = surf(DiffARVMatrOverEpoch4); view(270,90); +shading interp; +ylim ([1,5]); +xlim ([1,12]); +caxis([1 ColorScale/GainFactor]) + +TCPPort = 23456; +% Open the TCP socket +if verLessThan('matlab','9.12') + tcpSocket = tcpip('169.254.1.10', TCPPort, 'NetworkRole', 'client'); + tcpSocket.InputBufferSize = 2*NumChanVal(NCHsel)*FsampVal(FSsel); + fopen(tcpSocket); + set(tcpSocket, 'ByteOrder', 'littleEndian'); +else + % Open the TCP socket as client + tcpSocket = tcpclient('169.254.1.10',TCPPort); + tcpSocket.InputBufferSize = 2*NumChanVal(NCHsel)*FsampVal(FSsel); + fopen(tcpSocket); + set(tcpSocket, 'ByteOrder', 'little-endian'); +end + + +% Send the configuration to Quattrocento +fwrite(tcpSocket, ConfString, 'uint8'); + +tstart = tic; + +while tcpSocket.BytesAvailable <= NumChanVal(NCHsel)*NumSampBlockRead + pause(0.001); +end + +for i = 1 : NumCycles + + while toc(tstart) <= RefreshRate + pause(0.001); + end + + tstart = tic; + + try + % Read data + emg = fread(tcpSocket, [NumChanVal(NCHsel), NumSampBlockRead], 'int16')'; + + if(i > 2/RefreshRate) + EpochSegment = EpochSegment + 1; + if(EpochSegment > NumRefreshPerEpoch) + EpochSegment = 1; + end + + % Differential signals and ARV + for column = 1 : n_column + for row = 1 : n_row + % Differential signals are estimated by subtracting signals on + % adjacent matrix electrodes + + % Signals from Multiple IN1 (Channels 128..192) + SigDIF = emg(:,col{column}(row)+128) - emg(:,col{column}(row+1)+128); + ARV_diff = mean(abs(SigDIF)); %sqrt(sum(SigDIF.^2)/NumSampBlockRead); + DiffARVMatr1(column,row,EpochSegment) = ARV_diff; + + % Signals from Multiple IN2 (Channels 193..256) + SigDIF = emg(:,col{column}(row)+192) - emg(:,col{column}(row+1)+192); + ARV_diff = mean(abs(SigDIF)); %sqrt(sum(SigDIF.^2)/NumSampBlockRead); + DiffARVMatr2(column,row,EpochSegment) = ARV_diff; + + % Signals from Multiple IN3 (Channels 257..320) + SigDIF = emg(:,col{column}(row)+256) - emg(:,col{column}(row+1)+256); + ARV_diff = mean(abs(SigDIF)); %sqrt(sum(SigDIF.^2)/NumSampBlockRead); + DiffARVMatr3(column,row,EpochSegment) = ARV_diff; + + % Signals from Multiple IN4 (Channels 321..384) + SigDIF = emg(:,col{column}(row)+320) - emg(:,col{column}(row+1)+320); + ARV_diff = mean(abs(SigDIF)); %sqrt(sum(SigDIF.^2)/NumSampBlockRead); + DiffARVMatr4(column,row,EpochSegment) = ARV_diff; + end + end + + DiffARVMatrOverEpoch1 = mean(DiffARVMatr1,3); + DiffARVMatrOverEpoch2 = mean(DiffARVMatr2,3); + DiffARVMatrOverEpoch3 = mean(DiffARVMatr3,3); + DiffARVMatrOverEpoch4 = mean(DiffARVMatr4,3); + + gcf; +% + subplot(2,2,1) + h1.ZData = DiffARVMatrOverEpoch1; + shading interp; + + subplot(2,2,2) + h2.ZData = DiffARVMatrOverEpoch2; + shading interp; + + subplot(2,2,3) + h3.ZData = DiffARVMatrOverEpoch3; + shading interp; + + subplot(2,2,4) + h4.ZData = DiffARVMatrOverEpoch4; + shading interp; + +% plot(diff(emg(:,401))); +% title([num2str(i) '/' num2str(NumCycles)]); +% + + end + catch + break; + end +end + +% Stop data transfer. +% !!! Important !!! +% During debug always stop data transfer before start a new +% session otherwise the sinchronization with Quattrocento is lost. + +ConfString(1) = bin2dec('10000000'); % First byte that stop the data transer +ConfString(40) = CRC8(ConfString, 39); % Estimates the new CRC +fwrite(tcpSocket, ConfString, 'uint8'); + +% Close the communication +clear 'tcpSocket' diff --git a/docs/reference/otb/README.md b/docs/reference/otb/README.md index ac098a3..ba8e921 100644 --- a/docs/reference/otb/README.md +++ b/docs/reference/otb/README.md @@ -10,12 +10,17 @@ the Python implementation mirrors. | `Read_muovi.m` | OTB MATLAB example v3.0 | Direct single-Muovi connection (PC = TCP server :54321). Canonical control-byte + decode reference. | | `Read_muoviAP.m` | OTB MATLAB example v2.0 | SyncStation path (PC = TCP client 192.168.76.1:54320), CRC8-framed multi-probe command strings. | | `CRC8.m` | OTB MATLAB example | CRC-8 (poly `0x8C`, LSB-first, init 0) used by the SyncStation / Quattrocento framed commands. | +| `Read_Quattrocento.m` | OTB MATLAB example v5.0 | Direct Quattrocento connection (PC = TCP client `169.254.1.10:23456`). Canonical 40-byte config + little-endian decode reference. | +| `FourMapsQuattrocento.m` | OTB MATLAB example | Grid/channel-map helper (maps streamed channels to electrode grids). | +| `ReadFromOTBiolabLightQuattrocento.m` | OTB MATLAB example | Alternative path: read Quattrocento via the OT BioLab Light software server (not used by us; kept for reference). | Protocol verified against (manufacturer PDFs, not committed): - Muovi probe TCP Communication Protocol v2.4 - SyncStation TCP Communication Protocol v2.8 - MuoviPro User Manual v5.1 - MuoviLite User Manual v1.1 +- Quattrocento Configuration Protocol v1.7 +- Quattrocento User Manual Design spec: `docs/superpowers/specs/2026-06-03-otb-device-sources-design.md`. @@ -28,3 +33,13 @@ Key facts distilled from these references: `(device, mode)` — Muovi `NumChanVsMode = [38 22 38 38]`. - Channel map: 1–32 bio, 33–36 IMU quaternion W/X/Y/Z, 37 buffer+trigger, 38 sample counter. + +Quattrocento facts: +- PC is **TCP client** to **169.254.1.10:23456**; **little-endian** int16. +- 40-byte config string, CRC-8 trailer; `ACQ_SETT` byte0 = + `0x80 | decim<<6 | rec_on<<5 | fsamp<<3 | nch<<1 | acq_on`. +- fsamp 512/2048/5120/10240 Hz; nch → **120/216/312/408** streamed channels. +- Conversion: biosignal `5/2**16/150*1000` ≈ 5.086e-4 mV; AUX IN `5/2**16/0.5` + ≈ 1.526e-4 V; last 8 accessory raw (counter @ `n-7`, trigger @ `n-6`, + buffer @ `n-4`). +- Stop: byte0 = `0x80` + recomputed CRC (always stop before reconnect). diff --git a/docs/reference/otb/ReadFromOTBiolabLightQuattrocento.m b/docs/reference/otb/ReadFromOTBiolabLightQuattrocento.m new file mode 100644 index 0000000..570801f --- /dev/null +++ b/docs/reference/otb/ReadFromOTBiolabLightQuattrocento.m @@ -0,0 +1,57 @@ +%Script to READ signals from Quattrocento with OTBiolab Light. +%As an example, only the ramp signal is plotted, which gives us information on the correct communication with the device + +clear all +close all +fclose all +clc + + +nCh=384+16+8; % Set the number of channels required +channelToPlot=384+16+1; % Set the channel to plot (ramp) +fSample=2048; % Set sampling frequency +fRead=16; % Set reading frequency +nCycles=30*fRead; % Set number of read +timeSize = 2; + +handles.hPlot =plot(nan,nan); + + +tRead=zeros(1,nCycles); +dataAvailable=zeros(1,nCycles); + +if verLessThan('matlab','9.12') + tcpSocket = tcpip('localhost',31000); + set(tcpSocket, 'ByteOrder', 'littleEndian'); +else + tcpSocket = tcpclient('localhost',31000); + set(tcpSocket, 'ByteOrder', 'little-Endian'); +end + +tcpSocket.InputBufferSize=nCh*fSample*10; +fopen(tcpSocket); +fwrite(tcpSocket,'startTX'); +pause(0.01) +name=fread(tcpSocket,8); +disp((char(name'))); +dataToPlot=zeros(1,fSample*2); +t=[1/fSample:1/fSample:timeSize] - timeSize; +handles.hPlot=plot(t,dataToPlot); + +tic +for nCycle=1:nCycles + data=fread(tcpSocket,[nCh,fSample/fRead],'int16'); + dataChannel=data(channelToPlot,:); + t=t+(length(dataChannel)*1/fSample); + dataToPlot=[dataToPlot(length(dataChannel)+1:end) dataChannel]; + %dataToPlot(1:10) + set(handles.hPlot,'XData',t,'YData',dataToPlot); + title([num2str(toc) ' s']) + xlim([t(1) t(end)]) + ylim([-33000 33000]); + drawnow +end + +fwrite(tcpSocket,'stopTX'); + +fclose(tcpSocket); diff --git a/docs/reference/otb/Read_Quattrocento.m b/docs/reference/otb/Read_Quattrocento.m new file mode 100644 index 0000000..276bf29 --- /dev/null +++ b/docs/reference/otb/Read_Quattrocento.m @@ -0,0 +1,224 @@ +% Routine for the direct communication with Quattrocento +% This Matlab scripts doesn't need OT BioLab to communicate with Quattrocento. +% It opens a TCP socket, set the configuration of Quattrocento and reads back +% data at the desired sampling frequency. +% +% OT Bioelettronica +% June 15th 2023 +% v 5.0 + +close all +clear all + +NumCycles = 20; % How many times MatLab reads data from Quattrocento +PlotChan = [1:16]; % Channels to plot +PlotTime = 1; % Plot time in s +Decim = 64; % 0 or 64 + +offset = 5000; %32768; +Fsamp = [0 8 16 24]; % Codes to set the sampling frequency + +% Sampling frequency values +FsampVal = [512 2048 5120 10240]; +FSsel = 2; +% FSsel = 1 -> 512 Hz +% FSsel = 2 -> 2048 Hz +% FSsel = 3 -> 5120 Hz +% FSsel = 4 -> 10240 Hz +NumChan = [0 2 4 6]; % Codes to set the number of channels +% Channels numbers +NumChanVal = [120 216 312 408]; +NCHsel = 4; +% NCHsel = 1 -> IN1, IN2, MULTIPLE IN1, AUX IN +% NCHsel = 2 -> IN1..IN4, MULTIPLE IN1, MULTIPLE IN2, AUX IN +% NCHsel = 3 -> IN1..IN6, MULTIPLE IN1..MULTIPLE IN3, AUX IN +% NCHsel = 4 -> IN1..IN8, MULTIPLE IN1..MULTIPLE IN4, AUX IN +AnOutSource = 9; % Source input for analog output: +% 0 = the analog output signal came from IN1 +% 1 = the analog output signal came from IN2 +% 2 = the analog output signal came from IN3 +% 3 = the analog output signal came from IN4 +% 4 = the analog output signal came from IN5 +% 5 = the analog output signal came from IN6 +% 6 = the analog output signal came from IN7 +% 7 = the analog output signal came from IN8 +% 8 = the analog output signal came from MULTIPLE IN1 +% 9 = the analog output signal came from MULTIPLE IN2 +% 10 = the analog output signal came from MULTIPLE IN3 +% 11 = the analog output signal came from MULTIPLE IN4 +% 12 = the analog output signal came from AUX IN +AnOutChan = 0; % Channel for analog output +AnOutGain = bin2dec('00000000'); +% bin2dec('00000000') = Gain on the Analog output equal to 1 +% bin2dec('00010000') = Gain on the Analog output equal to 2 +% bin2dec('00100000') = Gain on the Analog output equal to 4 +% bin2dec('00110000') = Gain on the Analog output equal to 16 + +% Number of TCP socket port +TCPPort = 23456; + +GainFactor = 5/2^16/150*1000; %Provide amplitude in mV +% 5 is the ADC input swing +% 2^16 is the resolution +% 150 is the gain +% 1000 to get the mV + +AuxGainFactor = 5/2^16/0.5; % Gain factor to convert Aux Channels in V + +% Create the command to send to Quattrocento +ConfString(1) = bin2dec('10000000') + Decim + Fsamp(FSsel) + NumChan(NCHsel) + 1; +ConfString(2) = AnOutGain + AnOutSource; +ConfString(3) = AnOutChan; +% -------- IN 1 -------- % +ConfString(4) = 0; +ConfString(5) = 0; +ConfString(6) = bin2dec('00010100'); +% -------- IN 2 -------- % +ConfString(7) = 0; +ConfString(8) = 0; +ConfString(9) = bin2dec('00010100'); +% -------- IN 3 -------- % +ConfString(10) = 0; +ConfString(11) = 0; +ConfString(12) = bin2dec('00010100'); +% -------- IN 4 -------- % +ConfString(13) = 0; +ConfString(14) = 0; +ConfString(15) = bin2dec('00010100'); +% -------- IN 5 -------- % +ConfString(16) = 0; +ConfString(17) = 0; +ConfString(18) = bin2dec('00010100'); +% -------- IN 6 -------- % +ConfString(19) = 0; +ConfString(20) = 0; +ConfString(21) = bin2dec('00010100'); +% -------- IN 7 -------- % +ConfString(22) = 0; +ConfString(23) = 0; +ConfString(24) = bin2dec('00010100'); +% -------- IN 8 -------- % +ConfString(25) = 0; +ConfString(26) = 0; +ConfString(27) = bin2dec('00010100'); +% -------- MULTIPLE IN 1 -------- % +ConfString(28) = 0; +ConfString(29) = 0; +ConfString(30) = bin2dec('00010100'); +% -------- MULTIPLE IN 2 -------- % +ConfString(31) = 0; +ConfString(32) = 0; +ConfString(33) = bin2dec('00010100'); +% -------- MULTIPLE IN 3 -------- % +ConfString(34) = 0; +ConfString(35) = 0; +ConfString(36) = bin2dec('00010100'); +% -------- MULTIPLE IN 4 -------- % +ConfString(37) = 0; +ConfString(38) = 0; +ConfString(39) = bin2dec('00010100'); +% ---------- CRC8 ---------- % +ConfString(40) = CRC8(ConfString, 39); + +% Control channels +RampChan = NumChanVal(NCHsel)-7; +BuffChan = NumChanVal(NCHsel)-4; +TotSamp = 0; + +% The function tcpip is substituted by the functions +% tcpserver/tcpclient from Matlab version 2022a + +% Open the TCP socket +if verLessThan('matlab','9.12') + tcpSocket = tcpip('169.254.1.10', TCPPort, 'NetworkRole', 'client'); + tcpSocket.InputBufferSize = 2*NumChanVal(NCHsel)*FsampVal(FSsel); + fopen(tcpSocket); + set(tcpSocket, 'ByteOrder', 'littleEndian'); +else + % Open the TCP socket as client + tcpSocket = tcpclient('169.254.1.10',TCPPort); + tcpSocket.InputBufferSize = 2*NumChanVal(NCHsel)*FsampVal(FSsel); + fopen(tcpSocket); + set(tcpSocket, 'ByteOrder', 'little-endian'); +end + +% Send the configuration to Quattrocento +fwrite(tcpSocket, ConfString, 'uint8'); + +tstart = tic; +a = 0; + +ConfString(1) = ConfString(1) + bin2dec('00100000'); % Force the trigger to go hi (bit 5) +ConfString(40) = CRC8(ConfString, 39); % Estimates the new CRC +pause(1) + +fwrite(tcpSocket, ConfString, 'uint8'); + +% One second of data: 2 bytes * channels * Sampling frequency +% blockData = 2*NumChanVal(NCHsel)*FsampVal(FSsel); + +for i = 1 : NumCycles + + while toc(tstart) <= PlotTime + pause(0.001); + end + + tstart = tic; + + try + % Read data + + % Read one second of data into signed integer + Data{i} = fread(tcpSocket, [NumChanVal(NCHsel), FsampVal(FSsel)], 'int16')'; + + t = linspace(0, size(Data{i}, 1)/FsampVal(FSsel), size(Data{i}, 1)); + + % Plot the signals + % subplot(2,1,1) + for ch = PlotChan + plot(t, Data{i}(:,ch) + ch*offset) + hold on + end + hold off + title('Signals') + xlim([0 PlotTime]) + + % % Plot the buffer space + % subplot(4,1,3) + % plot(t, Data{i}(:,BuffChan)) + % xlim([0 PlotTime]) + % ylim([0 22000]) + % title('Buffer Space') + % + % % Plot the sample counter + % subplot(4,1,4) + % plot(t, Data{i}(:,RampChan)) + % % Alternatively, plot the difference between subsequent samples + % % It has to be always equal to 1 to check that no sample is lost: + % %plot(t(1:end-1), diff(Data{i}(:,RampChan))) + % xlim([0 PlotTime]) + % title('Sample Number') + % + % xlabel('Time(s)') + + catch + break; + end +end + +% Stop data transfer. +% !!! Important !!! +% During debug always stop data transfer before start a new +% session otherwise the sinchronization with Quattrocento is lost. + +ConfString(1) = bin2dec('10000000'); % First byte that stop the data transer +ConfString(40) = CRC8(ConfString, 39); % Estimates the new CRC + +% Stop the data transfer +fwrite(tcpSocket, ConfString, 'uint8'); + +% Close the communication +clear tcpSocket + + + diff --git a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md index 152ff6b..1d49506 100644 --- a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md +++ b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md @@ -108,8 +108,8 @@ canonical decode reference — the user provided `Read_muovi.m` v3.0 (direct pro `Read_muoviAP.m` v2.0 (SyncStation), and `CRC8.m`. **Vendor these into `docs/reference/otb/` during implementation** so the decode has a ground-truth reference next to the code (manufacturer example scripts, reference-only — not -imported/shipped). Quattrocento (§5.2) is still **bdi-sourced only** (no -manufacturer PDF yet) — provisional, verify before phase 2. +imported/shipped). Quattrocento (§5.2) is **also manufacturer-verified** now +(Configuration Protocol v1.7 + `Read_Quattrocento.m` v5.0). ### 5.1 Muovi / Muovi+ - **Socket role:** host (PC) is the **TCP server** on port **54321**; the Muovi @@ -201,26 +201,40 @@ manufacturer PDF yet) — provisional, verify before phase 2. trigger code / buffer — see §11.) - **Stop/disconnect:** resend `cfg - 1`, drain, close. -### 5.2 Quattrocento +### 5.2 Quattrocento — MANUFACTURER-VERIFIED +Verified against **Quattrocento Configuration Protocol v1.7** + OTB MATLAB +`Read_Quattrocento.m` v5.0 + `CRC8.m`. (bdi was substantially correct here — these +confirm it.) - **Socket role:** host is **TCP client**; dials the device. Default **169.254.1.10:23456** (link-local — host NIC needs a 169.254.x.x address). -- **Handshake:** none; after connect, send the 40-byte config; start toggles the - acquisition bit. -- **Config:** 40-byte packet. - - Byte 0 = `ACQ_SETT`: +- **Handshake:** none; after `connect`, send the 40-byte config; `ACQ_ON` (bit0 of + byte 0) toggles streaming. +- **Config — 40-byte packet** (`ConfString`): + - **Byte 0 = `ACQ_SETT`** (confirmed): ``` - acq = 1 << 7 - acq |= decim_active << 6 - acq |= recording_active << 5 - acq |= fs_mode(0..3) << 3 - acq |= n_channels_mode(0..3) << 1 - acq |= acquisition_active # bit0 (start/stop) + acq = 1 << 7 # bit7 fixed = 1 + acq |= decim << 6 # bit6 DECIM (sample @10240 then decimate) + acq |= rec_on << 5 # bit5 REC_ON (Trigger-OUT sync; optional) + acq |= fsamp(0..3) << 3 # bits4-3: 00=512 01=2048 10=5120 11=10240 Hz + acq |= nch(0..3) << 1 # bits2-1: channel-set selector (see Modes) + acq |= acq_on # bit0: 1=stream, 0=stop ``` - - Byte 1,2 = 0 (analog-out selectors, unused). - - Bytes 3–14 = IN1–IN4 (4×3-byte per-input config); 15–26 = IN5–IN8; - 27–38 = MULTIPLE IN 1–4 (4×3 bytes). Each 3-byte input config: - `byte3 = (side<<6)|(hp<<4)|(lp<<2)|detection`, bytes 1–2 = 0. - - Byte 39 = **CRC-8** over bytes 0..38 (poly `0x8C`, init 0, LSB-first): + - **Byte 1 = `AN_OUT_IN_SEL`** = `(anout_gain<<4) | insel`; **Byte 2 = + `AN_OUT_CH_SEL`**. Both default to `0` (analog-out from IN1 ch0, gain 1) — we + don't use the analog out. + - **Bytes 3–26 = IN1…IN8**, **bytes 27–38 = MULTIPLE IN1…IN4** — 12 inputs × + 3 bytes each: + - `CONF0` = `muscle` index (0–64; cosmetic) → `0`. + - `CONF1` = `(sensor<<3) | adapter` (electrode/adapter type; cosmetic) → `0`. + - `CONF2` = `(side<<6) | (hpf<<4) | (lpf<<2) | mode`. + - `mode`: **00=Monopolar, 01=Differential, 10=Bipolar**. + - `hpf`: 00=0.7, 01=10, 10=100, 11=200 Hz. + - `lpf`: 00=130, 01=500, 10=900, 11=4400 Hz. + - `side`: 00=not-def, 01=Left, 10=Right, 11=None. + - Default in `Read_Quattrocento.m` = `0x14` (monopolar, HPF 10 Hz, LPF + 500 Hz) — sensible default for all inputs. + - **Byte 39 = `CRC-8`** over bytes 0..38 (poly `0x8C`, init 0, LSB-first — + identical to `CRC8.m`): ```python def crc8(data, length): crc = 0 @@ -233,21 +247,26 @@ manufacturer PDF yet) — provisional, verify before phase 2. b >>= 1 return crc ``` - Start = `cfg[0] += 1`, recompute CRC, resend; stop = `cfg[0] -= 1`, - recompute CRC, resend. -- **Modes:** fs LOW=512 / MEDIUM=2048 / HIGH=5120 / ULTRA=10240 Hz; - streamed-channel count LOW=120 / MEDIUM=216 / HIGH=312 / ULTRA=408 (independent - of fs). Per-sample always int16 / 2 bytes; samples/frame = 64. - - Channel accounting: `streamed` = 120/216/312/408 (used for reshape); - `bio = len(grids)*64`; aux = 16; supplementary = 8. -- **Sample format:** **little-endian, signed int16** (` Date: Wed, 3 Jun 2026 22:16:21 +0200 Subject: [PATCH 09/26] docs(plan): implementation plan for OTB Muovi + Quattrocento sources TDD, bite-sized tasks: CRC8, pure decoders (big/little-endian, int24), device constants + command builders, shared _OTBSource base (socket buffering/framing/timestamps), MuoviSource (TCP server) + Quattrocento (TCP client), loopback tests over real sockets, example + how-to docs. GUI device-config, SyncStation, and Sessantaquattro deferred to follow-up plans. --- .../plans/2026-06-03-otb-device-sources.md | 1337 +++++++++++++++++ 1 file changed, 1337 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-otb-device-sources.md diff --git a/docs/superpowers/plans/2026-06-03-otb-device-sources.md b/docs/superpowers/plans/2026-06-03-otb-device-sources.md new file mode 100644 index 0000000..81bd135 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-otb-device-sources.md @@ -0,0 +1,1337 @@ +# OTB Device Sources (Muovi + Quattrocento) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add native, pure-Python (no Qt) `Source` implementations for OT Bioelettronica Muovi/Muovi+ and Quattrocento devices, restoring pre-2.0 hardware connectivity to MyoGestic 2.0. + +**Architecture:** A self-contained `myogestic/sources/otb/` package. Pure decode functions (`_decode.py`) and CRC (`_crc.py`) are unit-tested without hardware; a shared `_OTBSource` base (`_base.py`) owns socket lifecycle, frame buffering, timestamping, and the pull-based `Source` Protocol; thin per-device classes (`muovi.py`, `quattrocento.py`) supply socket role, config bytes, frame geometry, and conversion factors. Loopback tests stand up a fake device over a real TCP socket. Constants and the wire protocol are taken from manufacturer docs + OTB MATLAB reference (`docs/reference/otb/`). + +**Tech Stack:** Python 3.12+, stdlib `socket`/`struct`, NumPy, `mne_lsl.lsl.local_clock` (already a dependency), pytest, `uv run`. + +**Spec:** `docs/superpowers/specs/2026-06-03-otb-device-sources-design.md` + +**Scope:** This plan covers the two device sources + tests + an example + a docs page. **Out of scope (separate follow-up plans):** the GUI device-config panel + manual-connect acquire-loop change (spec §7), and the SyncStation multi-probe path (spec §11). + +**Conventions to follow (from the existing codebase):** +- A `Source` is a plain class (structural `typing.Protocol` — no base-class inheritance required by the framework). Methods: `connect() -> StreamInfo`, `read() -> tuple[np.ndarray | None, np.ndarray | None]`, `disconnect() -> None`; optional `discover() -> list[dict[str, str]]`, `reconnect(target: str | None = None) -> StreamInfo`. +- `read()` returns `(data, ts)` with `data` shape `(n_samples, n_channels)` float32 (sample-major) and `ts` shape `(n_samples,)` float64 in `mne_lsl.lsl.local_clock()` seconds; `(None, None)` when nothing is ready. +- `StreamInfo(n_channels: int, fs: float, dtype=np.dtype(np.float32), channel_names: list[str] | None = None)` from `myogestic.stream`. +- Tests live in `tests/`, pytest, run with `uv run pytest`. + +--- + +## File Structure + +``` +myogestic/sources/otb/ + __init__.py # exports MuoviSource, QuattrocentoSource + _crc.py # crc8() — poly 0x8C, LSB-first, init 0 + _decode.py # pure bytes -> (n_samples, n_channels) float32 decoders + _constants.py # device geometry, conversion factors, command builders + _base.py # _OTBSource: socket lifecycle + buffering + Source protocol + muovi.py # MuoviSource (TCP server role) + quattrocento.py # QuattrocentoSource (TCP client role) +tests/ + test_otb_crc.py + test_otb_decode.py + test_otb_muovi_loopback.py + test_otb_quattrocento_loopback.py +examples/otb/ + muovi_emg.py +docs/how-to/ + connect-otb-devices.md +``` + +--- + +## Task 1: CRC-8 (`_crc.py`) + +The SyncStation and Quattrocento framed commands end in a CRC-8 (poly 140 = `0x8C`, LSB-first, init 0). Ground truth: `docs/reference/otb/CRC8.m`. + +**Files:** +- Create: `myogestic/sources/otb/__init__.py` (empty for now) +- Create: `myogestic/sources/otb/_crc.py` +- Test: `tests/test_otb_crc.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_otb_crc.py +from myogestic.sources.otb._crc import crc8 + + +def test_crc8_empty_is_zero(): + assert crc8(bytes()) == 0 + + +def test_crc8_matches_matlab_reference_algorithm(): + # Reimplement docs/reference/otb/CRC8.m exactly and compare on a + # representative 39-byte Quattrocento config prefix. + def matlab_crc8(data: bytes) -> int: + crc = 0 + for byte in data: + extract = byte + for _ in range(8): + s = (crc % 2) ^ (extract % 2) + crc //= 2 + if s: + crc ^= 140 # 0x8C, matching the dec2bin(140,8) XOR in CRC8.m + extract //= 2 + crc &= 0xFF + return crc + + sample = bytes([0x80 | 8 | 6 | 1, 0, 0] + [0, 0, 0x14] * 12) # 39 bytes + assert len(sample) == 39 + assert crc8(sample) == matlab_crc8(sample) + + +def test_crc8_single_byte_known_value(): + # crc8 of a single zero byte stays 0 (no set bits to fold). + assert crc8(bytes([0x00])) == 0 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_otb_crc.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'myogestic.sources.otb._crc'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# myogestic/sources/otb/__init__.py +# (intentionally empty for now; populated in Task 6 / Task 10) +``` + +```python +# myogestic/sources/otb/_crc.py +"""CRC-8 used by OTB framed commands (SyncStation, Quattrocento). + +Polynomial 0x8C, init 0, LSB-first. Ported verbatim from OT Bioelettronica's +``CRC8.m`` (see docs/reference/otb/CRC8.m). +""" +from __future__ import annotations + + +def crc8(data: bytes) -> int: + """Return the OTB CRC-8 over ``data`` (poly 0x8C, init 0, LSB-first).""" + crc = 0 + for byte in data: + extract = byte + for _ in range(8): + summ = (crc & 1) ^ (extract & 1) + crc >>= 1 + if summ: + crc ^= 0x8C + extract >>= 1 + return crc & 0xFF +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_otb_crc.py -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add myogestic/sources/otb/__init__.py myogestic/sources/otb/_crc.py tests/test_otb_crc.py +git commit -m "feat(otb): add CRC-8 for OTB framed commands" +``` + +--- + +## Task 2: Muovi frame decode (`_decode.py`) + +Pure functions: raw frame bytes → `(n_samples, n_channels)` float32. Muovi is **big-endian, 2's-complement**, channels-contiguous per sample-instant (Fortran layout). EMG = int16, EEG = int24. Ground truth: `docs/reference/otb/Read_muovi.m`. + +**Files:** +- Create: `myogestic/sources/otb/_decode.py` +- Test: `tests/test_otb_decode.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_otb_decode.py +import numpy as np + +from myogestic.sources.otb._decode import decode_be_int16, decode_be_int24 + + +def _be_int16_bytes(values): + out = bytearray() + for v in values: + out += int(v & 0xFFFF).to_bytes(2, "big", signed=False) + return bytes(out) + + +def _be_int24_bytes(values): + out = bytearray() + for v in values: + out += int(v & 0xFFFFFF).to_bytes(3, "big", signed=False) + return bytes(out) + + +def test_decode_be_int16_shape_and_order(): + # 3 channels, 2 samples. Wire order is channels-contiguous per sample: + # [c0t0, c1t0, c2t0, c0t1, c1t1, c2t1] + raw = _be_int16_bytes([10, 20, 30, 11, 21, 31]) + out = decode_be_int16(raw, n_channels=3) + assert out.shape == (2, 3) # sample-major + assert out.dtype == np.float32 + np.testing.assert_array_equal(out[0], [10, 20, 30]) + np.testing.assert_array_equal(out[1], [11, 21, 31]) + + +def test_decode_be_int16_twos_complement(): + raw = _be_int16_bytes([-1, -32768, 32767]) + out = decode_be_int16(raw, n_channels=3) + np.testing.assert_array_equal(out[0], [-1, -32768, 32767]) + + +def test_decode_be_int24_twos_complement(): + raw = _be_int24_bytes([-1, 8388607, -8388608]) + out = decode_be_int24(raw, n_channels=3) + assert out.shape == (1, 3) + np.testing.assert_array_equal(out[0], [-1, 8388607, -8388608]) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_otb_decode.py -v` +Expected: FAIL with `ModuleNotFoundError` / `cannot import name 'decode_be_int16'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# myogestic/sources/otb/_decode.py +"""Pure decoders: raw OTB frame bytes -> (n_samples, n_channels) float32. + +OTB streams are channels-contiguous per sample-instant (Fortran/column-major +when shaped (n_channels, n_samples)); we transpose to sample-major to match the +MyoGestic Source contract. Muovi is big-endian; Quattrocento is little-endian +(see decode_le_int16 in Task 7). +""" +from __future__ import annotations + +import numpy as np + + +def decode_be_int16(raw: bytes, n_channels: int) -> np.ndarray: + """Big-endian signed int16, channels-contiguous -> (n_samples, n_channels) f32.""" + flat = np.frombuffer(raw, dtype=">i2").astype(np.float32) + return flat.reshape(n_channels, -1, order="F").T + + +def decode_be_int24(raw: bytes, n_channels: int) -> np.ndarray: + """Big-endian signed int24, channels-contiguous -> (n_samples, n_channels) f32. + + NumPy has no int24 dtype: read 3-byte groups MSB-first and sign-extend. + """ + b = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3).astype(np.int32) + vals = (b[:, 0] << 16) | (b[:, 1] << 8) | b[:, 2] + neg = vals >= 0x800000 + vals[neg] -= 0x1000000 + return vals.astype(np.float32).reshape(n_channels, -1, order="F").T +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_otb_decode.py -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add myogestic/sources/otb/_decode.py tests/test_otb_decode.py +git commit -m "feat(otb): add big-endian Muovi frame decoders" +``` + +--- + +## Task 3: Muovi constants & control byte (`_constants.py`) + +Encode the manufacturer geometry and the control byte `(EMG<<3) | (Mode<<1) | GO`. Ground truth: Muovi TCP Protocol v2.4 + `Read_muovi.m` (`Command = EMG*8 + Mode*2 + 1`, `ConvFact = 0.000286`). + +**Files:** +- Create: `myogestic/sources/otb/_constants.py` +- Test: add to `tests/test_otb_decode.py` (reuse the file for pure-unit tests) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_otb_decode.py +from myogestic.sources.otb import _constants as C + + +def test_muovi_control_byte_matches_matlab(): + # Read_muovi.m: Command = EMG*8 + Mode*2 + 1 + assert C.muovi_control_byte(emg=True, mode=0, go=True) == 0x09 + assert C.muovi_control_byte(emg=True, mode=1, go=True) == 0x0B + assert C.muovi_control_byte(emg=False, mode=0, go=True) == 0x01 + # stop = clear GO bit + assert C.muovi_control_byte(emg=True, mode=0, go=False) == 0x08 + + +def test_muovi_geometry_mode0(): + geo = C.muovi_geometry(plus=False, emg=True, mode=0) + assert geo.n_total == 38 # 32 bio + 6 aux + assert geo.n_bio == 32 + assert geo.fs == 2000.0 + assert geo.bytes_per_sample == 2 + + +def test_muovi_geometry_plus_eeg(): + geo = C.muovi_geometry(plus=True, emg=False, mode=0) + assert geo.n_total == 70 # 64 bio + 6 aux + assert geo.n_bio == 64 + assert geo.fs == 500.0 + assert geo.bytes_per_sample == 3 + + +def test_muovi_conversion_factor_gain8_mv(): + assert C.MUOVI_CONV_FACTOR_MV == 0.000286 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_otb_decode.py -v` +Expected: FAIL with `cannot import name '_constants'` / `AttributeError` + +- [ ] **Step 3: Write minimal implementation** + +```python +# myogestic/sources/otb/_constants.py +"""OTB device geometry, conversion factors, and command builders. + +Manufacturer-verified (Muovi TCP Protocol v2.4, MuoviLite manual v1.1, +Read_muovi.m v3.0). See docs/reference/otb/. +""" +from __future__ import annotations + +from dataclasses import dataclass + +# Muovi -------------------------------------------------------------------- + +MUOVI_PORT = 54321 +# Gain-8 LSB in mV (286.1 nV). Read_muovi.m uses 0.000286. +MUOVI_CONV_FACTOR_MV = 0.000286 +MUOVI_N_AUX = 6 # IMU quaternion W/X/Y/Z, buffer+trigger, sample counter + +# NumChanVsMode from Read_muovi.m: [38 22 38 38] (Muovi), Muovi+ adds 32 bio. +_MUOVI_BIO_BY_MODE = {0: 32, 1: 16, 2: 32, 3: 32} +_MUOVIPLUS_BIO_BY_MODE = {0: 64, 1: 32, 2: 64, 3: 64} + + +@dataclass(frozen=True) +class MuoviGeometry: + n_total: int # channels per sample-instant on the wire + n_bio: int # biosignal channels (first n_bio rows) + n_aux: int # auxiliary channels (always 6) + fs: float # 2000 (EMG) or 500 (EEG) + bytes_per_sample: int # 2 (EMG, int16) or 3 (EEG, int24) + + +def muovi_geometry(*, plus: bool, emg: bool, mode: int) -> MuoviGeometry: + """Channel/rate/width geometry for a (device, working-mode, detection-mode).""" + bio_table = _MUOVIPLUS_BIO_BY_MODE if plus else _MUOVI_BIO_BY_MODE + n_bio = bio_table[mode] + fs = 2000.0 if emg else 500.0 + bps = 2 if emg else 3 + return MuoviGeometry( + n_total=n_bio + MUOVI_N_AUX, + n_bio=n_bio, + n_aux=MUOVI_N_AUX, + fs=fs, + bytes_per_sample=bps, + ) + + +def muovi_control_byte(*, emg: bool, mode: int, go: bool) -> int: + """Muovi control byte: (EMG<<3) | (mode<<1) | GO. (Read_muovi.m formula.)""" + return (int(emg) << 3) | ((mode & 0x3) << 1) | int(go) + + +def muovi_channel_names(geo: MuoviGeometry) -> list[str]: + """Per-channel labels: bio then the 6 named aux channels.""" + names = [f"bio{i}" for i in range(geo.n_bio)] + names += ["imu_w", "imu_x", "imu_y", "imu_z", "buffer_trigger", "counter"] + return names +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_otb_decode.py -v` +Expected: PASS (all) + +- [ ] **Step 5: Commit** + +```bash +git add myogestic/sources/otb/_constants.py tests/test_otb_decode.py +git commit -m "feat(otb): add Muovi geometry + control-byte constants" +``` + +--- + +## Task 4: `_OTBSource` base (`_base.py`) + +Owns the read-side: a byte accumulator, complete-frame slicing, decode dispatch, and per-frame timestamp generation (`local_clock()` back-dated by `1/fs`). Subclasses provide socket setup (`_open`), start/stop commands (`_send_start`/`_send_stop`), `_frame_nbytes`, `_decode(frame) -> (n_samples_in_frame, n_channels)`, and the `StreamInfo`. + +**Files:** +- Create: `myogestic/sources/otb/_base.py` +- Test: `tests/test_otb_decode.py` (drive the base with a tiny fake subclass — no socket) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_otb_decode.py +from myogestic.sources.otb._base import _OTBSource +from myogestic.stream import StreamInfo + + +class _FakeOTB(_OTBSource): + """Drives the base buffering/decoding without a real socket.""" + def __init__(self): + super().__init__() + self._info = StreamInfo(n_channels=2, fs=4.0) + self._frame_nbytes = 2 * 2 # 2 channels x 1 sample x int16 + + def _open(self): + return self._info + + def _send_start(self): # no-op for the fake + pass + + def _send_stop(self): + pass + + def _decode(self, frame: bytes): + return decode_be_int16(frame, n_channels=2) + + # test helper: push bytes into the accumulator as if recv'd + def feed(self, raw: bytes): + self._buf.extend(raw) + + +def test_base_drain_returns_complete_frames_only(): + src = _FakeOTB() + src.connect() + # one and a half frames -> only the complete frame comes out + src.feed(_be_int16_bytes([5, 6]) + _be_int16_bytes([7])[:2]) + data, ts = src._drain() + assert data.shape == (1, 2) + np.testing.assert_array_equal(data[0], [5, 6]) + assert ts.shape == (1,) + # leftover (partial frame) stays buffered + assert len(src._buf) == 2 + + +def test_base_read_returns_none_when_empty(): + src = _FakeOTB() + src.connect() + assert src.read() == (None, None) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_otb_decode.py -v` +Expected: FAIL with `cannot import name '_OTBSource'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# myogestic/sources/otb/_base.py +"""Shared base for OTB socket sources. + +Owns the pull-side machinery common to every OTB device: a byte accumulator +fed from the socket, complete-frame slicing, decode dispatch, and per-frame +timestamping. Subclasses implement the socket/protocol specifics. +""" +from __future__ import annotations + +import socket + +import numpy as np +from mne_lsl.lsl import local_clock + +from myogestic.stream import StreamInfo + + +class _OTBSource: + """Base class for OTB device sources (Muovi, Quattrocento). + + Subclasses must set ``self._info`` (StreamInfo) and ``self._frame_nbytes`` + in ``_open()``, and implement ``_open``/``_send_start``/``_send_stop``/ + ``_decode``. ``self._sock`` is the connected/accepted socket used by + ``read`` for non-blocking recv. + """ + + def __init__(self) -> None: + self._sock: socket.socket | None = None + self._buf = bytearray() + self._info: StreamInfo | None = None + self._frame_nbytes: int = 0 + + # --- subclass hooks ----------------------------------------------------- + def _open(self) -> StreamInfo: + raise NotImplementedError + + def _send_start(self) -> None: + raise NotImplementedError + + def _send_stop(self) -> None: + raise NotImplementedError + + def _decode(self, frame: bytes) -> np.ndarray: + raise NotImplementedError + + # --- Source protocol ---------------------------------------------------- + def connect(self) -> StreamInfo: + self._buf.clear() + info = self._open() + self._info = info + self._send_start() + return info + + def read(self) -> tuple[np.ndarray | None, np.ndarray | None]: + if self._sock is not None: + try: + chunk = self._sock.recv(65536) + if chunk: + self._buf.extend(chunk) + except BlockingIOError: + pass + except OSError: + return None, None + return self._drain() + + def disconnect(self) -> None: + if self._sock is not None: + try: + self._send_stop() + except OSError: + pass + try: + self._sock.close() + finally: + self._sock = None + self._buf.clear() + + # --- internals ---------------------------------------------------------- + def _drain(self) -> tuple[np.ndarray | None, np.ndarray | None]: + """Slice all complete frames out of the buffer, decode, timestamp.""" + if self._frame_nbytes <= 0 or len(self._buf) < self._frame_nbytes: + return None, None + n_frames = len(self._buf) // self._frame_nbytes + take = n_frames * self._frame_nbytes + raw = bytes(self._buf[:take]) + del self._buf[:take] + data = self._decode(raw) + n = data.shape[0] + fs = float(self._info.fs) + end = local_clock() + ts = end - (np.arange(n - 1, -1, -1, dtype=np.float64) / fs) + return data, ts +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_otb_decode.py -v` +Expected: PASS (all) + +- [ ] **Step 5: Commit** + +```bash +git add myogestic/sources/otb/_base.py tests/test_otb_decode.py +git commit -m "feat(otb): add _OTBSource base (buffering, framing, timestamps)" +``` + +--- + +## Task 5: `MuoviSource` (`muovi.py`) + loopback test + +Muovi = PC is **TCP server** on 54321; the probe dials in. `connect()` binds/listens/accepts, returns `StreamInfo`, then `_send_start()` writes the control byte. Conversion to mV applied to bio (and unscaled aux). Loopback test: a fake "probe" connects as client and streams synthetic frames. + +**Files:** +- Create: `myogestic/sources/otb/muovi.py` +- Test: `tests/test_otb_muovi_loopback.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_otb_muovi_loopback.py +import socket +import threading +import time + +import numpy as np + +from myogestic.sources.otb._constants import muovi_control_byte, muovi_geometry +from myogestic.sources.otb.muovi import MuoviSource + + +def _be_int16_frame(values): + out = bytearray() + for v in values: + out += int(v & 0xFFFF).to_bytes(2, "big", signed=False) + return bytes(out) + + +def test_muovi_loopback_emg_mode0(): + geo = muovi_geometry(plus=False, emg=True, mode=0) # 38 ch, 2000 Hz, int16 + + # MuoviSource is the server; the fake probe is the client that dials in. + src = MuoviSource(host_ip="127.0.0.1", port=0, mode=0, emg=True) + info = src.connect_listen() # bind+listen, return the bound port (test hook) + port = src._server.getsockname()[1] + + received_cmd = [] + + def fake_probe(): + c = socket.create_connection(("127.0.0.1", port), timeout=2.0) + # one sample-instant: channels 0..37 valued 0..37 + frame = _be_int16_frame(list(range(geo.n_total))) + # read the control byte the source sends on start + c.settimeout(2.0) + for _ in range(20): + c.sendall(frame) + time.sleep(0.005) + try: + received_cmd.append(c.recv(1)) + except Exception: + pass + time.sleep(0.2) + c.close() + + t = threading.Thread(target=fake_probe, daemon=True) + t.start() + + stream_info = src.accept_and_start() # accept probe, send control byte + assert stream_info.n_channels == 32 # biosignal-only by default + assert stream_info.fs == 2000.0 + + # pull a few times + got = None + for _ in range(50): + data, ts = src.read() + if data is not None: + got = (data, ts) + break + time.sleep(0.02) + src.disconnect() + + assert got is not None + data, ts = got + assert data.shape[1] == 32 + # channel 0 raw was 0 -> 0 mV; channel 5 raw was 5 -> 5*0.000286 mV + np.testing.assert_allclose(data[0, 5], 5 * 0.000286, rtol=1e-5) +``` + +> Note: the test uses two small test-only entry points (`connect_listen`, `accept_and_start`) so the bind and the blocking `accept` can be separated in a single-threaded test. In normal use `connect()` does both (bind+listen+accept+start). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_otb_muovi_loopback.py -v` +Expected: FAIL with `ModuleNotFoundError: ...otb.muovi` + +- [ ] **Step 3: Write minimal implementation** + +```python +# myogestic/sources/otb/muovi.py +"""MuoviSource — native pure-Python source for OTB Muovi / Muovi+. + +PC is the TCP server on port 54321; the probe connects in as client (in AP +mode the probe is the WiFi access point and DHCP-assigns the PC). Big-endian +int16 (EMG) / int24 (EEG); see docs/reference/otb/Read_muovi.m. +""" +from __future__ import annotations + +import socket + +import numpy as np + +from myogestic.sources.otb import _constants as C +from myogestic.sources.otb._base import _OTBSource +from myogestic.sources.otb._decode import decode_be_int16, decode_be_int24 +from myogestic.stream import StreamInfo + + +class MuoviSource(_OTBSource): + """Connect to an OTB Muovi / Muovi+ probe over TCP. + + Args: + host_ip: Local interface to bind the server socket. ``""`` binds all. + port: TCP port to listen on (default 54321). ``0`` picks a free port + (used in tests). + plus: ``True`` for Muovi+ (64 bio channels), ``False`` for Muovi (32). + emg: ``True`` = EMG (2000 Hz, 16-bit), ``False`` = EEG (500 Hz, 24-bit). + mode: Detection mode 0..3. ``0`` = monopolar gain 8 (default; the only + unambiguous mode across firmware). Avoid mode 1 (firmware-dependent). + include_aux: Append the 6 aux channels (IMU/buffer/counter) unscaled. + accept_timeout: Seconds to wait for the probe to dial in. + """ + + def __init__( + self, + host_ip: str = "", + port: int = C.MUOVI_PORT, + *, + plus: bool = False, + emg: bool = True, + mode: int = 0, + include_aux: bool = False, + accept_timeout: float = 30.0, + ) -> None: + super().__init__() + self._host_ip = host_ip + self._port = port + self._plus = plus + self._emg = emg + self._mode = mode + self._include_aux = include_aux + self._accept_timeout = accept_timeout + self._server: socket.socket | None = None + self._geo = C.muovi_geometry(plus=plus, emg=emg, mode=mode) + + # --- normal entry point ------------------------------------------------- + def connect(self) -> StreamInfo: + """Bind+listen, accept the probe, send the start command.""" + self.connect_listen() + return self.accept_and_start() + + # --- split entry points (also used by tests) ---------------------------- + def connect_listen(self) -> None: + """Bind and listen; returns immediately (does not block on accept).""" + self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server.bind((self._host_ip, self._port)) + self._server.listen(1) + + def accept_and_start(self) -> StreamInfo: + """Block until the probe connects, then open + send the start command. + + Runs the base lifecycle inline (NOT via base ``connect()``) because the + server socket / accept is Muovi-specific. + """ + self._server.settimeout(self._accept_timeout) + conn, _addr = self._server.accept() + conn.setblocking(False) + self._sock = conn + info = self._open() + self._info = info + self._send_start() + return info + + # --- base hooks --------------------------------------------------------- + def _open(self) -> StreamInfo: + self._buf.clear() + n_out = self._geo.n_total if self._include_aux else self._geo.n_bio + self._frame_nbytes = self._geo.n_total * self._geo.bytes_per_sample + return StreamInfo( + n_channels=n_out, + fs=self._geo.fs, + dtype=np.dtype(np.float32), + channel_names=C.muovi_channel_names(self._geo)[:n_out], + ) + + def _send_start(self) -> None: + cmd = C.muovi_control_byte(emg=self._emg, mode=self._mode, go=True) + self._sock.sendall(bytes([cmd])) + + def _send_stop(self) -> None: + cmd = C.muovi_control_byte(emg=self._emg, mode=self._mode, go=False) + self._sock.sendall(bytes([cmd])) + + def _decode(self, frame: bytes) -> np.ndarray: + if self._geo.bytes_per_sample == 2: + full = decode_be_int16(frame, n_channels=self._geo.n_total) + else: + full = decode_be_int24(frame, n_channels=self._geo.n_total) + bio = full[:, : self._geo.n_bio] * np.float32(C.MUOVI_CONV_FACTOR_MV) + if not self._include_aux: + return bio + aux = full[:, self._geo.n_bio :] # unscaled IMU/buffer/counter + return np.concatenate([bio, aux], axis=1).astype(np.float32) + + def disconnect(self) -> None: + super().disconnect() + if self._server is not None: + try: + self._server.close() + finally: + self._server = None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_otb_muovi_loopback.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add myogestic/sources/otb/muovi.py tests/test_otb_muovi_loopback.py +git commit -m "feat(otb): add MuoviSource (TCP server, big-endian decode)" +``` + +--- + +## Task 6: Export `MuoviSource` + public-API test + +**Files:** +- Modify: `myogestic/sources/otb/__init__.py` +- Test: `tests/test_otb_muovi_loopback.py` (add an import test) + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_otb_muovi_loopback.py +def test_muovi_source_importable_from_package(): + from myogestic.sources.otb import MuoviSource as M + assert M is MuoviSource +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_otb_muovi_loopback.py::test_muovi_source_importable_from_package -v` +Expected: FAIL with `ImportError: cannot import name 'MuoviSource'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# myogestic/sources/otb/__init__.py +from myogestic.sources.otb.muovi import MuoviSource + +__all__ = ["MuoviSource"] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_otb_muovi_loopback.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add myogestic/sources/otb/__init__.py tests/test_otb_muovi_loopback.py +git commit -m "feat(otb): export MuoviSource from package" +``` + +--- + +## Task 7: Quattrocento decode (`_decode.py`) + +Quattrocento is **little-endian int16**, channels-contiguous per sample-instant. + +**Files:** +- Modify: `myogestic/sources/otb/_decode.py` +- Test: `tests/test_otb_decode.py` + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_otb_decode.py +from myogestic.sources.otb._decode import decode_le_int16 + + +def _le_int16_bytes(values): + out = bytearray() + for v in values: + out += int(v & 0xFFFF).to_bytes(2, "little", signed=False) + return bytes(out) + + +def test_decode_le_int16_shape_order_and_sign(): + raw = _le_int16_bytes([1, 2, 3, -1, -2, -3]) # 3 ch, 2 samples + out = decode_le_int16(raw, n_channels=3) + assert out.shape == (2, 3) + assert out.dtype == np.float32 + np.testing.assert_array_equal(out[0], [1, 2, 3]) + np.testing.assert_array_equal(out[1], [-1, -2, -3]) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_otb_decode.py -v` +Expected: FAIL with `cannot import name 'decode_le_int16'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# append to myogestic/sources/otb/_decode.py +def decode_le_int16(raw: bytes, n_channels: int) -> np.ndarray: + """Little-endian signed int16, channels-contiguous -> (n_samples, n_channels) f32.""" + flat = np.frombuffer(raw, dtype=" bytes: + """Build the 40-byte Quattrocento config string (with CRC-8 trailer).""" + acq_sett = ( + 0x80 + | (int(decim) << 6) + | (int(rec_on) << 5) + | ((fs_mode & 0x3) << 3) + | ((nch_mode & 0x3) << 1) + | int(acq_on) + ) + cfg = bytearray(40) + cfg[0] = acq_sett + cfg[1] = 0 # AN_OUT_IN_SEL (analog out unused) + cfg[2] = 0 # AN_OUT_CH_SEL + for i in range(12): # 8 IN + 4 MULTIPLE IN, 3 bytes each: CONF0/1/2 + base = 3 + i * 3 + cfg[base + 0] = 0 # CONF0 muscle + cfg[base + 1] = 0 # CONF1 sensor+adapter + cfg[base + 2] = conf2 & 0xFF + cfg[39] = crc8(bytes(cfg[:39])) + return bytes(cfg) + + +def quattro_channel_names(nch_total: int, n_bio: int) -> list[str]: + names = [f"bio{i}" for i in range(n_bio)] + names += [f"ch{i}" for i in range(n_bio, nch_total)] + # last 8 accessory: counter @ -7, trigger @ -6, buffer @ -4 + names[-7] = "counter" + names[-6] = "trigger" + names[-4] = "buffer" + return names +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_otb_decode.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add myogestic/sources/otb/_constants.py tests/test_otb_decode.py +git commit -m "feat(otb): add Quattrocento 40-byte config builder" +``` + +--- + +## Task 9: `QuattrocentoSource` (`quattrocento.py`) + loopback test + +Quattrocento = PC is **TCP client** to `169.254.1.10:23456`. `connect()` dials the device, sends the 40-byte config with `ACQ_ON`. Loopback test: a fake server accepts, validates the 40-byte config length + CRC, and streams synthetic frames. + +**Files:** +- Create: `myogestic/sources/otb/quattrocento.py` +- Test: `tests/test_otb_quattrocento_loopback.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_otb_quattrocento_loopback.py +import socket +import threading +import time + +import numpy as np + +from myogestic.sources.otb import _constants as C +from myogestic.sources.otb.quattrocento import QuattrocentoSource + + +def _le_int16_frame(values): + out = bytearray() + for v in values: + out += int(v & 0xFFFF).to_bytes(2, "little", signed=False) + return bytes(out) + + +def test_quattrocento_loopback_validates_config_and_streams(): + nch = C.QUATTRO_NCH_BY_MODE[0] # 120 channels (smallest) + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + port = srv.getsockname()[1] + + seen = {} + + def fake_device(): + conn, _ = srv.accept() + cfg = conn.recv(40) + seen["cfg_len"] = len(cfg) + seen["crc_ok"] = (cfg[39] == C.crc8(cfg[:39])) + frame = _le_int16_frame(list(range(nch))) + for _ in range(50): + conn.sendall(frame) + time.sleep(0.005) + time.sleep(0.2) + conn.close() + + t = threading.Thread(target=fake_device, daemon=True) + t.start() + + src = QuattrocentoSource(device_ip="127.0.0.1", port=port, + fs_mode=0, nch_mode=0, n_bio=64) + info = src.connect() + assert info.n_channels == 64 # biosignal-only by default + assert info.fs == 512.0 + + got = None + for _ in range(50): + data, ts = src.read() + if data is not None: + got = data + break + time.sleep(0.02) + src.disconnect() + srv.close() + + assert seen["cfg_len"] == 40 + assert seen["crc_ok"] is True + assert got is not None and got.shape[1] == 64 + # channel 10 raw=10 -> 10 * bio factor (mV) + np.testing.assert_allclose(got[0, 10], 10 * C.QUATTRO_CONV_FACTOR_MV, rtol=1e-5) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_otb_quattrocento_loopback.py -v` +Expected: FAIL with `ModuleNotFoundError: ...otb.quattrocento` + +- [ ] **Step 3: Write minimal implementation** + +```python +# myogestic/sources/otb/quattrocento.py +"""QuattrocentoSource — native pure-Python source for the OTB Quattrocento. + +PC is the TCP client to the amplifier (default 169.254.1.10:23456). Config is +a 40-byte CRC-8-terminated string; data is little-endian int16. See +docs/reference/otb/Read_Quattrocento.m. +""" +from __future__ import annotations + +import socket + +import numpy as np + +from myogestic.sources.otb import _constants as C +from myogestic.sources.otb._base import _OTBSource +from myogestic.sources.otb._decode import decode_le_int16 +from myogestic.stream import StreamInfo + + +class QuattrocentoSource(_OTBSource): + """Connect to an OTB Quattrocento amplifier over TCP. + + Args: + device_ip: Amplifier IP (default link-local 169.254.1.10). The host NIC + must have a 169.254.x.x address on that segment. + port: TCP port (default 23456). + fs_mode: 0..3 -> 512 / 2048 / 5120 / 10240 Hz. + nch_mode: 0..3 -> 120 / 216 / 312 / 408 streamed channels. + n_bio: Number of biosignal channels to expose (the grid channels at the + front of the stream). Defaults to all non-accessory channels. + include_aux: Append the AUX IN + accessory channels (unscaled). + connect_timeout: Seconds to wait for the TCP connect. + """ + + def __init__( + self, + device_ip: str = C.QUATTRO_IP, + port: int = C.QUATTRO_PORT, + *, + fs_mode: int = 1, + nch_mode: int = 1, + n_bio: int | None = None, + include_aux: bool = False, + connect_timeout: float = 10.0, + ) -> None: + super().__init__() + self._device_ip = device_ip + self._port = port + self._fs_mode = fs_mode + self._nch_mode = nch_mode + self._include_aux = include_aux + self._connect_timeout = connect_timeout + self._nch_total = C.QUATTRO_NCH_BY_MODE[nch_mode] + # default: everything except the 8 accessory channels is "bio" + self._n_bio = n_bio if n_bio is not None else self._nch_total - 8 + + # --- base hooks --------------------------------------------------------- + def _open(self) -> StreamInfo: + self._buf.clear() + sock = socket.create_connection( + (self._device_ip, self._port), timeout=self._connect_timeout + ) + sock.setblocking(False) + self._sock = sock + self._frame_nbytes = self._nch_total * 2 # int16, one sample-instant + n_out = self._nch_total if self._include_aux else self._n_bio + return StreamInfo( + n_channels=n_out, + fs=C.QUATTRO_FS_BY_MODE[self._fs_mode], + dtype=np.dtype(np.float32), + channel_names=C.quattro_channel_names(self._nch_total, self._n_bio)[:n_out], + ) + + def _send_start(self) -> None: + cfg = C.quattro_config(fs_mode=self._fs_mode, nch_mode=self._nch_mode, + acq_on=True) + self._sock.sendall(cfg) + + def _send_stop(self) -> None: + cfg = C.quattro_config(fs_mode=self._fs_mode, nch_mode=self._nch_mode, + acq_on=False) + self._sock.sendall(cfg) + + def _decode(self, frame: bytes) -> np.ndarray: + full = decode_le_int16(frame, n_channels=self._nch_total) + bio = full[:, : self._n_bio] * np.float32(C.QUATTRO_CONV_FACTOR_MV) + if not self._include_aux: + return bio + rest = full[:, self._n_bio :] # AUX IN + accessory, unscaled + return np.concatenate([bio, rest], axis=1).astype(np.float32) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_otb_quattrocento_loopback.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add myogestic/sources/otb/quattrocento.py tests/test_otb_quattrocento_loopback.py +git commit -m "feat(otb): add QuattrocentoSource (TCP client, little-endian decode)" +``` + +--- + +## Task 10: Export `QuattrocentoSource` + +**Files:** +- Modify: `myogestic/sources/otb/__init__.py` +- Test: `tests/test_otb_quattrocento_loopback.py` + +- [ ] **Step 1: Write the failing test** + +```python +# append to tests/test_otb_quattrocento_loopback.py +def test_quattrocento_importable_from_package(): + from myogestic.sources.otb import QuattrocentoSource as Q + assert Q is QuattrocentoSource +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_otb_quattrocento_loopback.py::test_quattrocento_importable_from_package -v` +Expected: FAIL with `ImportError` + +- [ ] **Step 3: Write minimal implementation** + +```python +# myogestic/sources/otb/__init__.py +from myogestic.sources.otb.muovi import MuoviSource +from myogestic.sources.otb.quattrocento import QuattrocentoSource + +__all__ = ["MuoviSource", "QuattrocentoSource"] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_otb_quattrocento_loopback.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add myogestic/sources/otb/__init__.py tests/test_otb_quattrocento_loopback.py +git commit -m "feat(otb): export QuattrocentoSource from package" +``` + +--- + +## Task 11: Example + docs + +**Files:** +- Create: `examples/otb/muovi_emg.py` +- Create: `docs/how-to/connect-otb-devices.md` + +- [ ] **Step 1: Write the example** + +```python +# examples/otb/muovi_emg.py +"""Acquire EMG from an OTB Muovi probe into a MyoGestic Stream. + +Setup: hold the Muovi power button ~5 s to start it as a WiFi access point, +join its "MVxxx-ID" network from this PC, then run this script. The PC acts as +the TCP server the probe dials into. +""" +from myogestic import Stream +from myogestic.sources.otb import MuoviSource + + +def main() -> None: + stream = Stream( + "emg", + source=MuoviSource(plus=False, emg=True, mode=0), # 32-ch gain-8 @2000Hz + window_seconds=1.0, + ) + stream.start() + print("Connected. Reading 5 windows...") + import time + + for _ in range(5): + time.sleep(1.0) + data, ts = stream.get_window() + if data is not None: + print(f"window: {data.shape} (channels-first), last ts={ts[-1]:.3f}") + stream.stop() + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Write the docs page** + +```markdown +# Connect OT Bioelettronica devices + +MyoGestic talks to OTB Muovi/Muovi+ and Quattrocento natively — no Qt, no +external bridge. Each device is a `Source` you drop into a `Stream`. + +## Muovi / Muovi+ (Wi-Fi) + +The PC is the TCP **server**; the probe connects to it. + +1. Hold the probe button ~5 s → it becomes a Wi-Fi access point `MVxxx-ID`. +2. Join that network from the PC. +3. ```python + from myogestic import Stream + from myogestic.sources.otb import MuoviSource + stream = Stream("emg", source=MuoviSource(plus=False, emg=True, mode=0), + window_seconds=1.0) + stream.start() + ``` + +Defaults: 32-ch (Muovi) monopolar gain-8 EMG @ 2000 Hz, biosignal-only +(286.1 nV/LSB → mV). Pass `plus=True` for 64-ch Muovi+, `emg=False` for EEG +(500 Hz, 24-bit), `include_aux=True` to also stream IMU/buffer/counter. + +## Quattrocento (Ethernet) + +The PC is the TCP **client** to the amplifier (default `169.254.1.10:23456`). +Give the PC NIC a `169.254.x.x` address on that segment. + +```python +from myogestic.sources.otb import QuattrocentoSource +stream = Stream("emg", source=QuattrocentoSource(fs_mode=1, nch_mode=1), + window_seconds=1.0) # 2048 Hz, 216 streamed ch +stream.start() +``` + +`nch_mode` 0..3 → 120/216/312/408 streamed channels; `fs_mode` 0..3 → +512/2048/5120/10240 Hz. Always stop the stream before reconnecting. + +> Protocol references: `docs/reference/otb/`. +``` + +- [ ] **Step 3: Smoke-check the example imports** + +Run: `uv run python -c "import ast; ast.parse(open('examples/otb/muovi_emg.py').read()); print('ok')"` +Expected: `ok` + +- [ ] **Step 4: Run the full OTB test suite** + +Run: `uv run pytest tests/test_otb_crc.py tests/test_otb_decode.py tests/test_otb_muovi_loopback.py tests/test_otb_quattrocento_loopback.py -v` +Expected: all PASS + +- [ ] **Step 5: Commit** + +```bash +git add examples/otb/muovi_emg.py docs/how-to/connect-otb-devices.md +git commit -m "docs(otb): add Muovi/Quattrocento example + how-to" +``` + +--- + +## Follow-up plans (out of scope here) + +1. **GUI device-config panel** (spec §7): source-agnostic `config_spec()`/`set_config()` optional Protocol extension + a generic `device_config` ImGui widget (Apply & Connect via `reconnect()`), plus the minimal manual-connect acquire-loop change (per-stream flag so LSL/Replay are untouched). +2. **SyncStation multi-probe path** (spec §11): a `SyncStationSource` (TCP client to `192.168.76.1:54320`, CRC-framed `START + CONTROL BYTEs` command builder) on the same `_OTBSource` base; needs its own channel-map verification. +3. **SessantaquattroSource** on the same base (Muovi-family protocol), validated when hardware is available. +4. **Hardware validation pass:** run Muovi (TEST mode `mode=3` ramps to confirm decode/endianness) and Quattrocento against real devices; capture a short byte dump into `docs/reference/otb/` as a regression fixture. From 3024abba1ea3c1198a6f56ee348d28bbdbf1aefb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:21:46 +0200 Subject: [PATCH 10/26] feat(otb): add CRC-8 for OTB framed commands --- myogestic/sources/otb/__init__.py | 1 + myogestic/sources/otb/_crc.py | 20 ++++++++++++++++++++ tests/test_otb_crc.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 myogestic/sources/otb/__init__.py create mode 100644 myogestic/sources/otb/_crc.py create mode 100644 tests/test_otb_crc.py diff --git a/myogestic/sources/otb/__init__.py b/myogestic/sources/otb/__init__.py new file mode 100644 index 0000000..467f847 --- /dev/null +++ b/myogestic/sources/otb/__init__.py @@ -0,0 +1 @@ +# (intentionally empty for now; populated in Task 6 / Task 10) diff --git a/myogestic/sources/otb/_crc.py b/myogestic/sources/otb/_crc.py new file mode 100644 index 0000000..1ae278f --- /dev/null +++ b/myogestic/sources/otb/_crc.py @@ -0,0 +1,20 @@ +"""CRC-8 used by OTB framed commands (SyncStation, Quattrocento). + +Polynomial 0x8C, init 0, LSB-first. Ported verbatim from OT Bioelettronica's +``CRC8.m`` (see docs/reference/otb/CRC8.m). +""" +from __future__ import annotations + + +def crc8(data: bytes) -> int: + """Return the OTB CRC-8 over ``data`` (poly 0x8C, init 0, LSB-first).""" + crc = 0 + for byte in data: + extract = byte + for _ in range(8): + summ = (crc & 1) ^ (extract & 1) + crc >>= 1 + if summ: + crc ^= 0x8C + extract >>= 1 + return crc & 0xFF diff --git a/tests/test_otb_crc.py b/tests/test_otb_crc.py new file mode 100644 index 0000000..d9c806e --- /dev/null +++ b/tests/test_otb_crc.py @@ -0,0 +1,31 @@ +from myogestic.sources.otb._crc import crc8 + + +def test_crc8_empty_is_zero(): + assert crc8(bytes()) == 0 + + +def test_crc8_matches_matlab_reference_algorithm(): + # Reimplement docs/reference/otb/CRC8.m exactly and compare on a + # representative 39-byte Quattrocento config prefix. + def matlab_crc8(data: bytes) -> int: + crc = 0 + for byte in data: + extract = byte + for _ in range(8): + s = (crc % 2) ^ (extract % 2) + crc //= 2 + if s: + crc ^= 140 # 0x8C, matching the dec2bin(140,8) XOR in CRC8.m + extract //= 2 + crc &= 0xFF + return crc + + sample = bytes([0x80 | 8 | 6 | 1, 0, 0] + [0, 0, 0x14] * 12) # 39 bytes + assert len(sample) == 39 + assert crc8(sample) == matlab_crc8(sample) + + +def test_crc8_single_byte_known_value(): + # crc8 of a single zero byte stays 0 (no set bits to fold). + assert crc8(bytes([0x00])) == 0 From fcd3134ec2354136c7e8a20749f40d29a0c66760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:22:21 +0200 Subject: [PATCH 11/26] feat(otb): add big-endian Muovi frame decoders --- myogestic/sources/otb/_decode.py | 28 ++++++++++++++++++++++ tests/test_otb_decode.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 myogestic/sources/otb/_decode.py create mode 100644 tests/test_otb_decode.py diff --git a/myogestic/sources/otb/_decode.py b/myogestic/sources/otb/_decode.py new file mode 100644 index 0000000..2d3ad16 --- /dev/null +++ b/myogestic/sources/otb/_decode.py @@ -0,0 +1,28 @@ +"""Pure decoders: raw OTB frame bytes -> (n_samples, n_channels) float32. + +OTB streams are channels-contiguous per sample-instant (Fortran/column-major +when shaped (n_channels, n_samples)); we transpose to sample-major to match the +MyoGestic Source contract. Muovi is big-endian; Quattrocento is little-endian +(see decode_le_int16 in Task 7). +""" +from __future__ import annotations + +import numpy as np + + +def decode_be_int16(raw: bytes, n_channels: int) -> np.ndarray: + """Big-endian signed int16, channels-contiguous -> (n_samples, n_channels) f32.""" + flat = np.frombuffer(raw, dtype=">i2").astype(np.float32) + return flat.reshape(n_channels, -1, order="F").T + + +def decode_be_int24(raw: bytes, n_channels: int) -> np.ndarray: + """Big-endian signed int24, channels-contiguous -> (n_samples, n_channels) f32. + + NumPy has no int24 dtype: read 3-byte groups MSB-first and sign-extend. + """ + b = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3).astype(np.int32) + vals = (b[:, 0] << 16) | (b[:, 1] << 8) | b[:, 2] + neg = vals >= 0x800000 + vals[neg] -= 0x1000000 + return vals.astype(np.float32).reshape(n_channels, -1, order="F").T diff --git a/tests/test_otb_decode.py b/tests/test_otb_decode.py new file mode 100644 index 0000000..f3cddd3 --- /dev/null +++ b/tests/test_otb_decode.py @@ -0,0 +1,41 @@ +import numpy as np + +from myogestic.sources.otb._decode import decode_be_int16, decode_be_int24 + + +def _be_int16_bytes(values): + out = bytearray() + for v in values: + out += int(v & 0xFFFF).to_bytes(2, "big", signed=False) + return bytes(out) + + +def _be_int24_bytes(values): + out = bytearray() + for v in values: + out += int(v & 0xFFFFFF).to_bytes(3, "big", signed=False) + return bytes(out) + + +def test_decode_be_int16_shape_and_order(): + # 3 channels, 2 samples. Wire order is channels-contiguous per sample: + # [c0t0, c1t0, c2t0, c0t1, c1t1, c2t1] + raw = _be_int16_bytes([10, 20, 30, 11, 21, 31]) + out = decode_be_int16(raw, n_channels=3) + assert out.shape == (2, 3) # sample-major + assert out.dtype == np.float32 + np.testing.assert_array_equal(out[0], [10, 20, 30]) + np.testing.assert_array_equal(out[1], [11, 21, 31]) + + +def test_decode_be_int16_twos_complement(): + raw = _be_int16_bytes([-1, -32768, 32767]) + out = decode_be_int16(raw, n_channels=3) + np.testing.assert_array_equal(out[0], [-1, -32768, 32767]) + + +def test_decode_be_int24_twos_complement(): + raw = _be_int24_bytes([-1, 8388607, -8388608]) + out = decode_be_int24(raw, n_channels=3) + assert out.shape == (1, 3) + np.testing.assert_array_equal(out[0], [-1, 8388607, -8388608]) From 9d5962d90176bc0a5a46c832c2c3a227e444fca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:23:08 +0200 Subject: [PATCH 12/26] feat(otb): add Muovi geometry + control-byte constants --- myogestic/sources/otb/_constants.py | 55 +++++++++++++++++++++++++++++ tests/test_otb_decode.py | 33 +++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 myogestic/sources/otb/_constants.py diff --git a/myogestic/sources/otb/_constants.py b/myogestic/sources/otb/_constants.py new file mode 100644 index 0000000..13cd4a8 --- /dev/null +++ b/myogestic/sources/otb/_constants.py @@ -0,0 +1,55 @@ +"""OTB device geometry, conversion factors, and command builders. + +Manufacturer-verified (Muovi TCP Protocol v2.4, MuoviLite manual v1.1, +Read_muovi.m v3.0). See docs/reference/otb/. +""" +from __future__ import annotations + +from dataclasses import dataclass + +# Muovi -------------------------------------------------------------------- + +MUOVI_PORT = 54321 +# Gain-8 LSB in mV (286.1 nV). Read_muovi.m uses 0.000286. +MUOVI_CONV_FACTOR_MV = 0.000286 +MUOVI_N_AUX = 6 # IMU quaternion W/X/Y/Z, buffer+trigger, sample counter + +# NumChanVsMode from Read_muovi.m: [38 22 38 38] (Muovi), Muovi+ adds 32 bio. +_MUOVI_BIO_BY_MODE = {0: 32, 1: 16, 2: 32, 3: 32} +_MUOVIPLUS_BIO_BY_MODE = {0: 64, 1: 32, 2: 64, 3: 64} + + +@dataclass(frozen=True) +class MuoviGeometry: + n_total: int # channels per sample-instant on the wire + n_bio: int # biosignal channels (first n_bio rows) + n_aux: int # auxiliary channels (always 6) + fs: float # 2000 (EMG) or 500 (EEG) + bytes_per_sample: int # 2 (EMG, int16) or 3 (EEG, int24) + + +def muovi_geometry(*, plus: bool, emg: bool, mode: int) -> MuoviGeometry: + """Channel/rate/width geometry for a (device, working-mode, detection-mode).""" + bio_table = _MUOVIPLUS_BIO_BY_MODE if plus else _MUOVI_BIO_BY_MODE + n_bio = bio_table[mode] + fs = 2000.0 if emg else 500.0 + bps = 2 if emg else 3 + return MuoviGeometry( + n_total=n_bio + MUOVI_N_AUX, + n_bio=n_bio, + n_aux=MUOVI_N_AUX, + fs=fs, + bytes_per_sample=bps, + ) + + +def muovi_control_byte(*, emg: bool, mode: int, go: bool) -> int: + """Muovi control byte: (EMG<<3) | (mode<<1) | GO. (Read_muovi.m formula.)""" + return (int(emg) << 3) | ((mode & 0x3) << 1) | int(go) + + +def muovi_channel_names(geo: MuoviGeometry) -> list[str]: + """Per-channel labels: bio then the 6 named aux channels.""" + names = [f"bio{i}" for i in range(geo.n_bio)] + names += ["imu_w", "imu_x", "imu_y", "imu_z", "buffer_trigger", "counter"] + return names diff --git a/tests/test_otb_decode.py b/tests/test_otb_decode.py index f3cddd3..3dfd452 100644 --- a/tests/test_otb_decode.py +++ b/tests/test_otb_decode.py @@ -39,3 +39,36 @@ def test_decode_be_int24_twos_complement(): out = decode_be_int24(raw, n_channels=3) assert out.shape == (1, 3) np.testing.assert_array_equal(out[0], [-1, 8388607, -8388608]) + + +# Task 3: constants tests +from myogestic.sources.otb import _constants as C + + +def test_muovi_control_byte_matches_matlab(): + # Read_muovi.m: Command = EMG*8 + Mode*2 + 1 + assert C.muovi_control_byte(emg=True, mode=0, go=True) == 0x09 + assert C.muovi_control_byte(emg=True, mode=1, go=True) == 0x0B + assert C.muovi_control_byte(emg=False, mode=0, go=True) == 0x01 + # stop = clear GO bit + assert C.muovi_control_byte(emg=True, mode=0, go=False) == 0x08 + + +def test_muovi_geometry_mode0(): + geo = C.muovi_geometry(plus=False, emg=True, mode=0) + assert geo.n_total == 38 # 32 bio + 6 aux + assert geo.n_bio == 32 + assert geo.fs == 2000.0 + assert geo.bytes_per_sample == 2 + + +def test_muovi_geometry_plus_eeg(): + geo = C.muovi_geometry(plus=True, emg=False, mode=0) + assert geo.n_total == 70 # 64 bio + 6 aux + assert geo.n_bio == 64 + assert geo.fs == 500.0 + assert geo.bytes_per_sample == 3 + + +def test_muovi_conversion_factor_gain8_mv(): + assert C.MUOVI_CONV_FACTOR_MV == 0.000286 From ec70f41705d52c6de1d867c5c40ddbc019fe3b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:24:02 +0200 Subject: [PATCH 13/26] feat(otb): add _OTBSource base (buffering, framing, timestamps) --- myogestic/sources/otb/_base.py | 91 ++++++++++++++++++++++++++++++++++ tests/test_otb_decode.py | 48 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 myogestic/sources/otb/_base.py diff --git a/myogestic/sources/otb/_base.py b/myogestic/sources/otb/_base.py new file mode 100644 index 0000000..67acb47 --- /dev/null +++ b/myogestic/sources/otb/_base.py @@ -0,0 +1,91 @@ +"""Shared base for OTB socket sources. + +Owns the pull-side machinery common to every OTB device: a byte accumulator +fed from the socket, complete-frame slicing, decode dispatch, and per-frame +timestamping. Subclasses implement the socket/protocol specifics. +""" +from __future__ import annotations + +import socket + +import numpy as np +from mne_lsl.lsl import local_clock + +from myogestic.stream import StreamInfo + + +class _OTBSource: + """Base class for OTB device sources (Muovi, Quattrocento). + + Subclasses must set ``self._info`` (StreamInfo) and ``self._frame_nbytes`` + in ``_open()``, and implement ``_open``/``_send_start``/``_send_stop``/ + ``_decode``. ``self._sock`` is the connected/accepted socket used by + ``read`` for non-blocking recv. + """ + + def __init__(self) -> None: + self._sock: socket.socket | None = None + self._buf = bytearray() + self._info: StreamInfo | None = None + self._frame_nbytes: int = 0 + + # --- subclass hooks ----------------------------------------------------- + def _open(self) -> StreamInfo: + raise NotImplementedError + + def _send_start(self) -> None: + raise NotImplementedError + + def _send_stop(self) -> None: + raise NotImplementedError + + def _decode(self, frame: bytes) -> np.ndarray: + raise NotImplementedError + + # --- Source protocol ---------------------------------------------------- + def connect(self) -> StreamInfo: + self._buf.clear() + info = self._open() + self._info = info + self._send_start() + return info + + def read(self) -> tuple[np.ndarray | None, np.ndarray | None]: + if self._sock is not None: + try: + chunk = self._sock.recv(65536) + if chunk: + self._buf.extend(chunk) + except BlockingIOError: + pass + except OSError: + return None, None + return self._drain() + + def disconnect(self) -> None: + if self._sock is not None: + try: + self._send_stop() + except OSError: + pass + try: + self._sock.close() + finally: + self._sock = None + self._buf.clear() + + # --- internals ---------------------------------------------------------- + def _drain(self) -> tuple[np.ndarray | None, np.ndarray | None]: + """Slice all complete frames out of the buffer, decode, timestamp.""" + if self._frame_nbytes <= 0 or len(self._buf) < self._frame_nbytes: + return None, None + n_frames = len(self._buf) // self._frame_nbytes + take = n_frames * self._frame_nbytes + raw = bytes(self._buf[:take]) + del self._buf[:take] + data = self._decode(raw) + n = data.shape[0] + fs = float(self._info.fs) + end = local_clock() + ts = end - (np.arange(n - 1, -1, -1, dtype=np.float64) / fs) + return data, ts diff --git a/tests/test_otb_decode.py b/tests/test_otb_decode.py index 3dfd452..34e0732 100644 --- a/tests/test_otb_decode.py +++ b/tests/test_otb_decode.py @@ -72,3 +72,51 @@ def test_muovi_geometry_plus_eeg(): def test_muovi_conversion_factor_gain8_mv(): assert C.MUOVI_CONV_FACTOR_MV == 0.000286 + + +# Task 4: _OTBSource base tests +from myogestic.sources.otb._base import _OTBSource +from myogestic.stream import StreamInfo + + +class _FakeOTB(_OTBSource): + """Drives the base buffering/decoding without a real socket.""" + def __init__(self): + super().__init__() + self._info = StreamInfo(n_channels=2, fs=4.0) + self._frame_nbytes = 2 * 2 # 2 channels x 1 sample x int16 + + def _open(self): + return self._info + + def _send_start(self): # no-op for the fake + pass + + def _send_stop(self): + pass + + def _decode(self, frame: bytes): + return decode_be_int16(frame, n_channels=2) + + # test helper: push bytes into the accumulator as if recv'd + def feed(self, raw: bytes): + self._buf.extend(raw) + + +def test_base_drain_returns_complete_frames_only(): + src = _FakeOTB() + src.connect() + # one and a half frames -> only the complete frame comes out + src.feed(_be_int16_bytes([5, 6]) + _be_int16_bytes([7])[:2]) + data, ts = src._drain() + assert data.shape == (1, 2) + np.testing.assert_array_equal(data[0], [5, 6]) + assert ts.shape == (1,) + # leftover (partial frame) stays buffered + assert len(src._buf) == 2 + + +def test_base_read_returns_none_when_empty(): + src = _FakeOTB() + src.connect() + assert src.read() == (None, None) From baf4e359e5fe153cd57f913104557d3c80a43964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:25:07 +0200 Subject: [PATCH 14/26] feat(otb): add MuoviSource (TCP server, big-endian decode) --- myogestic/sources/otb/muovi.py | 122 +++++++++++++++++++++++++++++++ tests/test_otb_muovi_loopback.py | 65 ++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 myogestic/sources/otb/muovi.py create mode 100644 tests/test_otb_muovi_loopback.py diff --git a/myogestic/sources/otb/muovi.py b/myogestic/sources/otb/muovi.py new file mode 100644 index 0000000..67576cc --- /dev/null +++ b/myogestic/sources/otb/muovi.py @@ -0,0 +1,122 @@ +"""MuoviSource — native pure-Python source for OTB Muovi / Muovi+. + +PC is the TCP server on port 54321; the probe connects in as client (in AP +mode the probe is the WiFi access point and DHCP-assigns the PC). Big-endian +int16 (EMG) / int24 (EEG); see docs/reference/otb/Read_muovi.m. +""" +from __future__ import annotations + +import socket + +import numpy as np + +from myogestic.sources.otb import _constants as C +from myogestic.sources.otb._base import _OTBSource +from myogestic.sources.otb._decode import decode_be_int16, decode_be_int24 +from myogestic.stream import StreamInfo + + +class MuoviSource(_OTBSource): + """Connect to an OTB Muovi / Muovi+ probe over TCP. + + Args: + host_ip: Local interface to bind the server socket. ``""`` binds all. + port: TCP port to listen on (default 54321). ``0`` picks a free port + (used in tests). + plus: ``True`` for Muovi+ (64 bio channels), ``False`` for Muovi (32). + emg: ``True`` = EMG (2000 Hz, 16-bit), ``False`` = EEG (500 Hz, 24-bit). + mode: Detection mode 0..3. ``0`` = monopolar gain 8 (default; the only + unambiguous mode across firmware). Avoid mode 1 (firmware-dependent). + include_aux: Append the 6 aux channels (IMU/buffer/counter) unscaled. + accept_timeout: Seconds to wait for the probe to dial in. + """ + + def __init__( + self, + host_ip: str = "", + port: int = C.MUOVI_PORT, + *, + plus: bool = False, + emg: bool = True, + mode: int = 0, + include_aux: bool = False, + accept_timeout: float = 30.0, + ) -> None: + super().__init__() + self._host_ip = host_ip + self._port = port + self._plus = plus + self._emg = emg + self._mode = mode + self._include_aux = include_aux + self._accept_timeout = accept_timeout + self._server: socket.socket | None = None + self._geo = C.muovi_geometry(plus=plus, emg=emg, mode=mode) + + # --- normal entry point ------------------------------------------------- + def connect(self) -> StreamInfo: + """Bind+listen, accept the probe, send the start command.""" + self.connect_listen() + return self.accept_and_start() + + # --- split entry points (also used by tests) ---------------------------- + def connect_listen(self) -> None: + """Bind and listen; returns immediately (does not block on accept).""" + self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server.bind((self._host_ip, self._port)) + self._server.listen(1) + + def accept_and_start(self) -> StreamInfo: + """Block until the probe connects, then open + send the start command. + + Runs the base lifecycle inline (NOT via base ``connect()``) because the + server socket / accept is Muovi-specific. + """ + self._server.settimeout(self._accept_timeout) + conn, _addr = self._server.accept() + conn.setblocking(False) + self._sock = conn + info = self._open() + self._info = info + self._send_start() + return info + + # --- base hooks --------------------------------------------------------- + def _open(self) -> StreamInfo: + self._buf.clear() + n_out = self._geo.n_total if self._include_aux else self._geo.n_bio + self._frame_nbytes = self._geo.n_total * self._geo.bytes_per_sample + return StreamInfo( + n_channels=n_out, + fs=self._geo.fs, + dtype=np.dtype(np.float32), + channel_names=C.muovi_channel_names(self._geo)[:n_out], + ) + + def _send_start(self) -> None: + cmd = C.muovi_control_byte(emg=self._emg, mode=self._mode, go=True) + self._sock.sendall(bytes([cmd])) + + def _send_stop(self) -> None: + cmd = C.muovi_control_byte(emg=self._emg, mode=self._mode, go=False) + self._sock.sendall(bytes([cmd])) + + def _decode(self, frame: bytes) -> np.ndarray: + if self._geo.bytes_per_sample == 2: + full = decode_be_int16(frame, n_channels=self._geo.n_total) + else: + full = decode_be_int24(frame, n_channels=self._geo.n_total) + bio = full[:, : self._geo.n_bio] * np.float32(C.MUOVI_CONV_FACTOR_MV) + if not self._include_aux: + return bio + aux = full[:, self._geo.n_bio :] # unscaled IMU/buffer/counter + return np.concatenate([bio, aux], axis=1).astype(np.float32) + + def disconnect(self) -> None: + super().disconnect() + if self._server is not None: + try: + self._server.close() + finally: + self._server = None diff --git a/tests/test_otb_muovi_loopback.py b/tests/test_otb_muovi_loopback.py new file mode 100644 index 0000000..00fc249 --- /dev/null +++ b/tests/test_otb_muovi_loopback.py @@ -0,0 +1,65 @@ +import socket +import threading +import time + +import numpy as np + +from myogestic.sources.otb._constants import muovi_control_byte, muovi_geometry +from myogestic.sources.otb.muovi import MuoviSource + + +def _be_int16_frame(values): + out = bytearray() + for v in values: + out += int(v & 0xFFFF).to_bytes(2, "big", signed=False) + return bytes(out) + + +def test_muovi_loopback_emg_mode0(): + geo = muovi_geometry(plus=False, emg=True, mode=0) # 38 ch, 2000 Hz, int16 + + # MuoviSource is the server; the fake probe is the client that dials in. + src = MuoviSource(host_ip="127.0.0.1", port=0, mode=0, emg=True) + info = src.connect_listen() # bind+listen, return the bound port (test hook) + port = src._server.getsockname()[1] + + received_cmd = [] + + def fake_probe(): + c = socket.create_connection(("127.0.0.1", port), timeout=2.0) + # one sample-instant: channels 0..37 valued 0..37 + frame = _be_int16_frame(list(range(geo.n_total))) + # read the control byte the source sends on start + c.settimeout(2.0) + for _ in range(20): + c.sendall(frame) + time.sleep(0.005) + try: + received_cmd.append(c.recv(1)) + except Exception: + pass + time.sleep(0.2) + c.close() + + t = threading.Thread(target=fake_probe, daemon=True) + t.start() + + stream_info = src.accept_and_start() # accept probe, send control byte + assert stream_info.n_channels == 32 # biosignal-only by default + assert stream_info.fs == 2000.0 + + # pull a few times + got = None + for _ in range(50): + data, ts = src.read() + if data is not None: + got = (data, ts) + break + time.sleep(0.02) + src.disconnect() + + assert got is not None + data, ts = got + assert data.shape[1] == 32 + # channel 0 raw was 0 -> 0 mV; channel 5 raw was 5 -> 5*0.000286 mV + np.testing.assert_allclose(data[0, 5], 5 * 0.000286, rtol=1e-5) From bf61ca0d6d46d109de12e8f51ee7ab7575168b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:25:33 +0200 Subject: [PATCH 15/26] feat(otb): export MuoviSource from package --- myogestic/sources/otb/__init__.py | 4 +++- tests/test_otb_muovi_loopback.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/myogestic/sources/otb/__init__.py b/myogestic/sources/otb/__init__.py index 467f847..2c2442d 100644 --- a/myogestic/sources/otb/__init__.py +++ b/myogestic/sources/otb/__init__.py @@ -1 +1,3 @@ -# (intentionally empty for now; populated in Task 6 / Task 10) +from myogestic.sources.otb.muovi import MuoviSource + +__all__ = ["MuoviSource"] diff --git a/tests/test_otb_muovi_loopback.py b/tests/test_otb_muovi_loopback.py index 00fc249..704dac0 100644 --- a/tests/test_otb_muovi_loopback.py +++ b/tests/test_otb_muovi_loopback.py @@ -63,3 +63,8 @@ def fake_probe(): assert data.shape[1] == 32 # channel 0 raw was 0 -> 0 mV; channel 5 raw was 5 -> 5*0.000286 mV np.testing.assert_allclose(data[0, 5], 5 * 0.000286, rtol=1e-5) + + +def test_muovi_source_importable_from_package(): + from myogestic.sources.otb import MuoviSource as M + assert M is MuoviSource From bf08734b83e3c8b3430a055778eb70a7bc6028e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:35:41 +0200 Subject: [PATCH 16/26] test(otb): silence post-teardown socket noise in Muovi loopback test Wrap the fake-probe thread body so sendall/recv after the source closes the socket no longer print a spurious traceback. No change to assertions. --- tests/test_otb_muovi_loopback.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/test_otb_muovi_loopback.py b/tests/test_otb_muovi_loopback.py index 704dac0..5b8b358 100644 --- a/tests/test_otb_muovi_loopback.py +++ b/tests/test_otb_muovi_loopback.py @@ -26,20 +26,22 @@ def test_muovi_loopback_emg_mode0(): received_cmd = [] def fake_probe(): - c = socket.create_connection(("127.0.0.1", port), timeout=2.0) - # one sample-instant: channels 0..37 valued 0..37 - frame = _be_int16_frame(list(range(geo.n_total))) - # read the control byte the source sends on start - c.settimeout(2.0) - for _ in range(20): - c.sendall(frame) - time.sleep(0.005) + # The probe streams until the source disconnects mid-loop; once the + # source closes the socket, sendall/recv raise — swallow those so the + # daemon thread exits cleanly without printing a spurious traceback. try: + c = socket.create_connection(("127.0.0.1", port), timeout=2.0) + # one sample-instant: channels 0..37 valued 0..37 + frame = _be_int16_frame(list(range(geo.n_total))) + c.settimeout(2.0) + for _ in range(20): + c.sendall(frame) + time.sleep(0.005) received_cmd.append(c.recv(1)) - except Exception: + time.sleep(0.2) + c.close() + except OSError: pass - time.sleep(0.2) - c.close() t = threading.Thread(target=fake_probe, daemon=True) t.start() From 8aa400c3db220ed9aaf63dcd466297744bd7d768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:37:36 +0200 Subject: [PATCH 17/26] feat(otb): add little-endian Quattrocento decoder --- myogestic/sources/otb/_decode.py | 6 ++++++ tests/test_otb_decode.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/myogestic/sources/otb/_decode.py b/myogestic/sources/otb/_decode.py index 2d3ad16..aa514c5 100644 --- a/myogestic/sources/otb/_decode.py +++ b/myogestic/sources/otb/_decode.py @@ -26,3 +26,9 @@ def decode_be_int24(raw: bytes, n_channels: int) -> np.ndarray: neg = vals >= 0x800000 vals[neg] -= 0x1000000 return vals.astype(np.float32).reshape(n_channels, -1, order="F").T + + +def decode_le_int16(raw: bytes, n_channels: int) -> np.ndarray: + """Little-endian signed int16, channels-contiguous -> (n_samples, n_channels) f32.""" + flat = np.frombuffer(raw, dtype=" Date: Wed, 3 Jun 2026 22:38:46 +0200 Subject: [PATCH 18/26] feat(otb): add Quattrocento 40-byte config builder --- myogestic/sources/otb/_constants.py | 58 +++++++++++++++++++++++++++++ tests/test_otb_decode.py | 21 +++++++++++ 2 files changed, 79 insertions(+) diff --git a/myogestic/sources/otb/_constants.py b/myogestic/sources/otb/_constants.py index 13cd4a8..234c76b 100644 --- a/myogestic/sources/otb/_constants.py +++ b/myogestic/sources/otb/_constants.py @@ -53,3 +53,61 @@ def muovi_channel_names(geo: MuoviGeometry) -> list[str]: names = [f"bio{i}" for i in range(geo.n_bio)] names += ["imu_w", "imu_x", "imu_y", "imu_z", "buffer_trigger", "counter"] return names + + +# Quattrocento ------------------------------------------------------------- +from myogestic.sources.otb._crc import crc8 # re-exported for callers/tests # noqa: E402 + +QUATTRO_IP = "169.254.1.10" +QUATTRO_PORT = 23456 +QUATTRO_FS_BY_MODE = {0: 512.0, 1: 2048.0, 2: 5120.0, 3: 10240.0} +QUATTRO_NCH_BY_MODE = {0: 120, 1: 216, 2: 312, 3: 408} +# Read_Quattrocento.m: GainFactor = 5/2^16/150*1000 (mV); AuxGain = 5/2^16/0.5 (V) +QUATTRO_CONV_FACTOR_MV = 5 / 2 ** 16 / 150 * 1000 +QUATTRO_AUX_FACTOR_V = 5 / 2 ** 16 / 0.5 +# Default per-input CONF2 (Read_Quattrocento.m = 0x14): monopolar, HPF 10Hz, LPF 500Hz +_QUATTRO_DEFAULT_CONF2 = 0x14 + + +def quattro_config( + *, + fs_mode: int, + nch_mode: int, + acq_on: bool, + decim: bool = False, + rec_on: bool = False, + conf2: int = _QUATTRO_DEFAULT_CONF2, +) -> bytes: + """Build the 40-byte Quattrocento config string (with CRC-8 trailer).""" + if acq_on: + acq_sett = ( + 0x80 + | (int(decim) << 6) + | (int(rec_on) << 5) + | ((fs_mode & 0x3) << 3) + | ((nch_mode & 0x3) << 1) + | 1 + ) + else: + acq_sett = 0x80 + cfg = bytearray(40) + cfg[0] = acq_sett + cfg[1] = 0 # AN_OUT_IN_SEL (analog out unused) + cfg[2] = 0 # AN_OUT_CH_SEL + for i in range(12): # 8 IN + 4 MULTIPLE IN, 3 bytes each: CONF0/1/2 + base = 3 + i * 3 + cfg[base + 0] = 0 # CONF0 muscle + cfg[base + 1] = 0 # CONF1 sensor+adapter + cfg[base + 2] = conf2 & 0xFF + cfg[39] = crc8(bytes(cfg[:39])) + return bytes(cfg) + + +def quattro_channel_names(nch_total: int, n_bio: int) -> list[str]: + names = [f"bio{i}" for i in range(n_bio)] + names += [f"ch{i}" for i in range(n_bio, nch_total)] + # last 8 accessory: counter @ -7, trigger @ -6, buffer @ -4 + names[-7] = "counter" + names[-6] = "trigger" + names[-4] = "buffer" + return names diff --git a/tests/test_otb_decode.py b/tests/test_otb_decode.py index f3df562..1d8a312 100644 --- a/tests/test_otb_decode.py +++ b/tests/test_otb_decode.py @@ -140,3 +140,24 @@ def test_decode_le_int16_shape_order_and_sign(): assert out.dtype == np.float32 np.testing.assert_array_equal(out[0], [1, 2, 3]) np.testing.assert_array_equal(out[1], [-1, -2, -3]) + + +# Task 8: Quattrocento config builder +def test_quattro_channel_counts_and_factors(): + assert C.QUATTRO_NCH_BY_MODE == {0: 120, 1: 216, 2: 312, 3: 408} + assert C.QUATTRO_FS_BY_MODE == {0: 512.0, 1: 2048.0, 2: 5120.0, 3: 10240.0} + assert abs(C.QUATTRO_CONV_FACTOR_MV - (5 / 2 ** 16 / 150 * 1000)) < 1e-12 + + +def test_quattro_config_is_40_bytes_with_valid_crc(): + cfg = C.quattro_config(fs_mode=1, nch_mode=3, acq_on=True) + assert len(cfg) == 40 + # byte0 = 0x80 | fsamp(01<<3=8) | nch(11<<1=6) | acq_on(1) = 0x80|8|6|1 + assert cfg[0] == (0x80 | 8 | 6 | 1) + # CRC trailer is valid over the first 39 bytes + assert cfg[39] == C.crc8(cfg[:39]) + + +def test_quattro_stop_config_byte0(): + cfg = C.quattro_config(fs_mode=1, nch_mode=3, acq_on=False) + assert cfg[0] == 0x80 From 75d205f8c0f24f6844a596b14c8c82960a693d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:40:10 +0200 Subject: [PATCH 19/26] feat(otb): add QuattrocentoSource (TCP client, little-endian decode) --- myogestic/sources/otb/quattrocento.py | 89 +++++++++++++++++++++++++ tests/test_otb_quattrocento_loopback.py | 64 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 myogestic/sources/otb/quattrocento.py create mode 100644 tests/test_otb_quattrocento_loopback.py diff --git a/myogestic/sources/otb/quattrocento.py b/myogestic/sources/otb/quattrocento.py new file mode 100644 index 0000000..54d8aba --- /dev/null +++ b/myogestic/sources/otb/quattrocento.py @@ -0,0 +1,89 @@ +"""QuattrocentoSource — native pure-Python source for the OTB Quattrocento. + +PC is the TCP client to the amplifier (default 169.254.1.10:23456). Config is +a 40-byte CRC-8-terminated string; data is little-endian int16. See +docs/reference/otb/Read_Quattrocento.m. +""" +from __future__ import annotations + +import socket + +import numpy as np + +from myogestic.sources.otb import _constants as C +from myogestic.sources.otb._base import _OTBSource +from myogestic.sources.otb._decode import decode_le_int16 +from myogestic.stream import StreamInfo + + +class QuattrocentoSource(_OTBSource): + """Connect to an OTB Quattrocento amplifier over TCP. + + Args: + device_ip: Amplifier IP (default link-local 169.254.1.10). The host NIC + must have a 169.254.x.x address on that segment. + port: TCP port (default 23456). + fs_mode: 0..3 -> 512 / 2048 / 5120 / 10240 Hz. + nch_mode: 0..3 -> 120 / 216 / 312 / 408 streamed channels. + n_bio: Number of biosignal channels to expose (the grid channels at the + front of the stream). Defaults to all non-accessory channels. + include_aux: Append the AUX IN + accessory channels (unscaled). + connect_timeout: Seconds to wait for the TCP connect. + """ + + def __init__( + self, + device_ip: str = C.QUATTRO_IP, + port: int = C.QUATTRO_PORT, + *, + fs_mode: int = 1, + nch_mode: int = 1, + n_bio: int | None = None, + include_aux: bool = False, + connect_timeout: float = 10.0, + ) -> None: + super().__init__() + self._device_ip = device_ip + self._port = port + self._fs_mode = fs_mode + self._nch_mode = nch_mode + self._include_aux = include_aux + self._connect_timeout = connect_timeout + self._nch_total = C.QUATTRO_NCH_BY_MODE[nch_mode] + # default: everything except the 8 accessory channels is "bio" + self._n_bio = n_bio if n_bio is not None else self._nch_total - 8 + + # --- base hooks --------------------------------------------------------- + def _open(self) -> StreamInfo: + self._buf.clear() + sock = socket.create_connection( + (self._device_ip, self._port), timeout=self._connect_timeout + ) + sock.setblocking(False) + self._sock = sock + self._frame_nbytes = self._nch_total * 2 # int16, one sample-instant + n_out = self._nch_total if self._include_aux else self._n_bio + return StreamInfo( + n_channels=n_out, + fs=C.QUATTRO_FS_BY_MODE[self._fs_mode], + dtype=np.dtype(np.float32), + channel_names=C.quattro_channel_names(self._nch_total, self._n_bio)[:n_out], + ) + + def _send_start(self) -> None: + cfg = C.quattro_config(fs_mode=self._fs_mode, nch_mode=self._nch_mode, + acq_on=True) + self._sock.sendall(cfg) + + def _send_stop(self) -> None: + cfg = C.quattro_config(fs_mode=self._fs_mode, nch_mode=self._nch_mode, + acq_on=False) + self._sock.sendall(cfg) + + def _decode(self, frame: bytes) -> np.ndarray: + full = decode_le_int16(frame, n_channels=self._nch_total) + bio = full[:, : self._n_bio] * np.float32(C.QUATTRO_CONV_FACTOR_MV) + if not self._include_aux: + return bio + rest = full[:, self._n_bio :] # AUX IN + accessory, unscaled + return np.concatenate([bio, rest], axis=1).astype(np.float32) diff --git a/tests/test_otb_quattrocento_loopback.py b/tests/test_otb_quattrocento_loopback.py new file mode 100644 index 0000000..dc47d1b --- /dev/null +++ b/tests/test_otb_quattrocento_loopback.py @@ -0,0 +1,64 @@ +# tests/test_otb_quattrocento_loopback.py +import socket +import threading +import time + +import numpy as np + +from myogestic.sources.otb import _constants as C +from myogestic.sources.otb.quattrocento import QuattrocentoSource + + +def _le_int16_frame(values): + out = bytearray() + for v in values: + out += int(v & 0xFFFF).to_bytes(2, "little", signed=False) + return bytes(out) + + +def test_quattrocento_loopback_validates_config_and_streams(): + nch = C.QUATTRO_NCH_BY_MODE[0] # 120 channels (smallest) + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + port = srv.getsockname()[1] + + seen = {} + + def fake_device(): + conn, _ = srv.accept() + cfg = conn.recv(40) + seen["cfg_len"] = len(cfg) + seen["crc_ok"] = (cfg[39] == C.crc8(cfg[:39])) + frame = _le_int16_frame(list(range(nch))) + for _ in range(50): + conn.sendall(frame) + time.sleep(0.005) + time.sleep(0.2) + conn.close() + + t = threading.Thread(target=fake_device, daemon=True) + t.start() + + src = QuattrocentoSource(device_ip="127.0.0.1", port=port, + fs_mode=0, nch_mode=0, n_bio=64) + info = src.connect() + assert info.n_channels == 64 # biosignal-only by default + assert info.fs == 512.0 + + got = None + for _ in range(50): + data, ts = src.read() + if data is not None: + got = data + break + time.sleep(0.02) + src.disconnect() + srv.close() + + assert seen["cfg_len"] == 40 + assert seen["crc_ok"] is True + assert got is not None and got.shape[1] == 64 + # channel 10 raw=10 -> 10 * bio factor (mV) + np.testing.assert_allclose(got[0, 10], 10 * C.QUATTRO_CONV_FACTOR_MV, rtol=1e-5) From e621bc79263ba4a45a8694b9708b0c436fde17f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:40:42 +0200 Subject: [PATCH 20/26] feat(otb): export QuattrocentoSource from package --- myogestic/sources/otb/__init__.py | 3 ++- tests/test_otb_quattrocento_loopback.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/myogestic/sources/otb/__init__.py b/myogestic/sources/otb/__init__.py index 2c2442d..2c4f20a 100644 --- a/myogestic/sources/otb/__init__.py +++ b/myogestic/sources/otb/__init__.py @@ -1,3 +1,4 @@ from myogestic.sources.otb.muovi import MuoviSource +from myogestic.sources.otb.quattrocento import QuattrocentoSource -__all__ = ["MuoviSource"] +__all__ = ["MuoviSource", "QuattrocentoSource"] diff --git a/tests/test_otb_quattrocento_loopback.py b/tests/test_otb_quattrocento_loopback.py index dc47d1b..4a97a8c 100644 --- a/tests/test_otb_quattrocento_loopback.py +++ b/tests/test_otb_quattrocento_loopback.py @@ -62,3 +62,9 @@ def fake_device(): assert got is not None and got.shape[1] == 64 # channel 10 raw=10 -> 10 * bio factor (mV) np.testing.assert_allclose(got[0, 10], 10 * C.QUATTRO_CONV_FACTOR_MV, rtol=1e-5) + + +# Task 10: export +def test_quattrocento_importable_from_package(): + from myogestic.sources.otb import QuattrocentoSource as Q + assert Q is QuattrocentoSource From 66768ace9c84b408a6981fc2be85fad865c41128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:47:25 +0200 Subject: [PATCH 21/26] refactor(otb): move crc8 import to top of _constants (drop E402 noqa) Code-quality review fix: the crc8 re-export was imported mid-file with a suppressed E402; _crc is a leaf module with no circular-import risk, so it belongs with the top-of-file imports. --- myogestic/sources/otb/_constants.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/myogestic/sources/otb/_constants.py b/myogestic/sources/otb/_constants.py index 234c76b..1bfb77d 100644 --- a/myogestic/sources/otb/_constants.py +++ b/myogestic/sources/otb/_constants.py @@ -7,6 +7,8 @@ from dataclasses import dataclass +from myogestic.sources.otb._crc import crc8 # re-exported for callers/tests + # Muovi -------------------------------------------------------------------- MUOVI_PORT = 54321 @@ -56,7 +58,6 @@ def muovi_channel_names(geo: MuoviGeometry) -> list[str]: # Quattrocento ------------------------------------------------------------- -from myogestic.sources.otb._crc import crc8 # re-exported for callers/tests # noqa: E402 QUATTRO_IP = "169.254.1.10" QUATTRO_PORT = 23456 From 588f98a88b4ec2ca57341fd23a6149f11dccdf54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 22:49:02 +0200 Subject: [PATCH 22/26] docs(otb): add Muovi example + connect-OTB-devices how-to --- docs/how-to/connect-otb-devices.md | 39 ++++++++++++++++++++++++++++++ examples/otb/muovi_emg.py | 30 +++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 docs/how-to/connect-otb-devices.md create mode 100644 examples/otb/muovi_emg.py diff --git a/docs/how-to/connect-otb-devices.md b/docs/how-to/connect-otb-devices.md new file mode 100644 index 0000000..c9ae53e --- /dev/null +++ b/docs/how-to/connect-otb-devices.md @@ -0,0 +1,39 @@ +# Connect OT Bioelettronica devices + +MyoGestic talks to OTB Muovi/Muovi+ and Quattrocento natively — no Qt, no +external bridge. Each device is a `Source` you drop into a `Stream`. + +## Muovi / Muovi+ (Wi-Fi) + +The PC is the TCP **server**; the probe connects to it. + +1. Hold the probe button ~5 s → it becomes a Wi-Fi access point `MVxxx-ID`. +2. Join that network from the PC. +3. ```python + from myogestic import Stream + from myogestic.sources.otb import MuoviSource + stream = Stream("emg", source=MuoviSource(plus=False, emg=True, mode=0), + window_seconds=1.0) + stream.start() + ``` + +Defaults: 32-ch (Muovi) monopolar gain-8 EMG @ 2000 Hz, biosignal-only +(286.1 nV/LSB → mV). Pass `plus=True` for 64-ch Muovi+, `emg=False` for EEG +(500 Hz, 24-bit), `include_aux=True` to also stream IMU/buffer/counter. + +## Quattrocento (Ethernet) + +The PC is the TCP **client** to the amplifier (default `169.254.1.10:23456`). +Give the PC NIC a `169.254.x.x` address on that segment. + +```python +from myogestic.sources.otb import QuattrocentoSource +stream = Stream("emg", source=QuattrocentoSource(fs_mode=1, nch_mode=1), + window_seconds=1.0) # 2048 Hz, 216 streamed ch +stream.start() +``` + +`nch_mode` 0..3 → 120/216/312/408 streamed channels; `fs_mode` 0..3 → +512/2048/5120/10240 Hz. Always stop the stream before reconnecting. + +> Protocol references: `docs/reference/otb/`. diff --git a/examples/otb/muovi_emg.py b/examples/otb/muovi_emg.py new file mode 100644 index 0000000..49e0487 --- /dev/null +++ b/examples/otb/muovi_emg.py @@ -0,0 +1,30 @@ +"""Acquire EMG from an OTB Muovi probe into a MyoGestic Stream. + +Setup: hold the Muovi power button ~5 s to start it as a WiFi access point, +join its "MVxxx-ID" network from this PC, then run this script. The PC acts as +the TCP server the probe dials into. +""" +from myogestic import Stream +from myogestic.sources.otb import MuoviSource + + +def main() -> None: + stream = Stream( + "emg", + source=MuoviSource(plus=False, emg=True, mode=0), # 32-ch gain-8 @2000Hz + window_seconds=1.0, + ) + stream.start() + print("Connected. Reading 5 windows...") + import time + + for _ in range(5): + time.sleep(1.0) + data, ts = stream.get_window() + if data is not None: + print(f"window: {data.shape} (channels-first), last ts={ts[-1]:.3f}") + stream.stop() + + +if __name__ == "__main__": + main() From 1f642d0336ecd3d2d9749363693502d2e07cf2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 23:06:15 +0200 Subject: [PATCH 23/26] fix(otb): address final-review findings (disconnect, leaks, channel names) - H2: empty recv (peer close) now drops the socket after flushing buffered frames, so the acquire loop no longer spins on a dead connection. - H3: connect()/accept_and_start() clean up the socket if _send_start raises. - H1: fix off-by-one in Quattrocento accessory channel names (counter/trigger/ buffer were one position too high vs Read_Quattrocento.m RampChan/BuffChan). - M2/M4: close any stale server/client socket on re-listen / reconnect. - M1/L1: fix misleading loopback test comment; move example import to top. - Add regression tests for the peer-close and start-failure paths. --- examples/otb/muovi_emg.py | 3 +- myogestic/sources/otb/_base.py | 12 ++++++- myogestic/sources/otb/_constants.py | 11 ++++--- myogestic/sources/otb/muovi.py | 11 ++++++- myogestic/sources/otb/quattrocento.py | 5 +++ tests/test_otb_decode.py | 46 +++++++++++++++++++++++++++ tests/test_otb_muovi_loopback.py | 2 +- 7 files changed, 82 insertions(+), 8 deletions(-) diff --git a/examples/otb/muovi_emg.py b/examples/otb/muovi_emg.py index 49e0487..c30e1c1 100644 --- a/examples/otb/muovi_emg.py +++ b/examples/otb/muovi_emg.py @@ -4,6 +4,8 @@ join its "MVxxx-ID" network from this PC, then run this script. The PC acts as the TCP server the probe dials into. """ +import time + from myogestic import Stream from myogestic.sources.otb import MuoviSource @@ -16,7 +18,6 @@ def main() -> None: ) stream.start() print("Connected. Reading 5 windows...") - import time for _ in range(5): time.sleep(1.0) diff --git a/myogestic/sources/otb/_base.py b/myogestic/sources/otb/_base.py index 67acb47..06487b5 100644 --- a/myogestic/sources/otb/_base.py +++ b/myogestic/sources/otb/_base.py @@ -47,7 +47,11 @@ def connect(self) -> StreamInfo: self._buf.clear() info = self._open() self._info = info - self._send_start() + try: + self._send_start() + except Exception: + self.disconnect() # don't leak the opened socket on a failed start + raise return info def read(self) -> tuple[np.ndarray | None, np.ndarray | None]: @@ -56,6 +60,12 @@ def read(self) -> tuple[np.ndarray | None, np.ndarray | None]: chunk = self._sock.recv(65536) if chunk: self._buf.extend(chunk) + else: + # Empty recv = peer closed the connection. Drop the socket so + # the acquire loop stops spinning, but still flush any whole + # frames already buffered (handled by _drain below). + self._sock.close() + self._sock = None except BlockingIOError: pass except OSError: diff --git a/myogestic/sources/otb/_constants.py b/myogestic/sources/otb/_constants.py index 1bfb77d..59f254d 100644 --- a/myogestic/sources/otb/_constants.py +++ b/myogestic/sources/otb/_constants.py @@ -107,8 +107,11 @@ def quattro_config( def quattro_channel_names(nch_total: int, n_bio: int) -> list[str]: names = [f"bio{i}" for i in range(n_bio)] names += [f"ch{i}" for i in range(n_bio, nch_total)] - # last 8 accessory: counter @ -7, trigger @ -6, buffer @ -4 - names[-7] = "counter" - names[-6] = "trigger" - names[-4] = "buffer" + # The 8 accessory channels are the last 8 (0-indexed names[-8:]). + # Read_Quattrocento.m: counter (RampChan) = nch-7 (1-indexed) -> names[-8]; + # buffer (BuffChan) = nch-4 (1-indexed) -> names[-5]; trigger is the + # accessory channel between them (config protocol v1.7) -> names[-7]. + names[-8] = "counter" + names[-7] = "trigger" + names[-5] = "buffer" return names diff --git a/myogestic/sources/otb/muovi.py b/myogestic/sources/otb/muovi.py index 67576cc..9dd7341 100644 --- a/myogestic/sources/otb/muovi.py +++ b/myogestic/sources/otb/muovi.py @@ -62,6 +62,11 @@ def connect(self) -> StreamInfo: # --- split entry points (also used by tests) ---------------------------- def connect_listen(self) -> None: """Bind and listen; returns immediately (does not block on accept).""" + if self._server is not None: # don't leak a prior server on re-listen + try: + self._server.close() + finally: + self._server = None self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self._server.bind((self._host_ip, self._port)) @@ -79,7 +84,11 @@ def accept_and_start(self) -> StreamInfo: self._sock = conn info = self._open() self._info = info - self._send_start() + try: + self._send_start() + except Exception: + self.disconnect() # don't leak the accepted socket on a failed start + raise return info # --- base hooks --------------------------------------------------------- diff --git a/myogestic/sources/otb/quattrocento.py b/myogestic/sources/otb/quattrocento.py index 54d8aba..eb7446d 100644 --- a/myogestic/sources/otb/quattrocento.py +++ b/myogestic/sources/otb/quattrocento.py @@ -56,6 +56,11 @@ def __init__( # --- base hooks --------------------------------------------------------- def _open(self) -> StreamInfo: self._buf.clear() + if self._sock is not None: # don't leak a prior socket on reconnect + try: + self._sock.close() + finally: + self._sock = None sock = socket.create_connection( (self._device_ip, self._port), timeout=self._connect_timeout ) diff --git a/tests/test_otb_decode.py b/tests/test_otb_decode.py index 1d8a312..625a1ef 100644 --- a/tests/test_otb_decode.py +++ b/tests/test_otb_decode.py @@ -1,4 +1,8 @@ +import socket +import time + import numpy as np +import pytest from myogestic.sources.otb._decode import decode_be_int16, decode_be_int24 @@ -161,3 +165,45 @@ def test_quattro_config_is_40_bytes_with_valid_crc(): def test_quattro_stop_config_byte0(): cfg = C.quattro_config(fs_mode=1, nch_mode=3, acq_on=False) assert cfg[0] == 0x80 + + +def test_base_read_handles_peer_close_without_spinning(): + """Empty recv (peer closed) must flush buffered frames, then drop the + socket so the acquire loop stops polling a dead connection.""" + a, b = socket.socketpair() + src = _FakeOTB() + src.connect() # sets _info + frame_nbytes; _sock starts None + a.setblocking(False) + src._sock = a + b.sendall(_be_int16_bytes([5, 6])) # one complete frame (2ch x 1 sample) + b.close() + time.sleep(0.05) + + data, ts = src.read() # drains the buffered frame + assert data is not None and data.shape == (1, 2) + np.testing.assert_array_equal(data[0], [5, 6]) + + data2, ts2 = src.read() # peer gone -> drop socket, no spin + assert (data2, ts2) == (None, None) + assert src._sock is None + a.close() + + +def test_base_connect_cleans_up_socket_when_start_fails(): + """If _send_start raises, connect() must not leak the opened socket.""" + + class _FailStart(_FakeOTB): + def _open(self): + self._a, self._b = socket.socketpair() + self._sock = self._a + self._frame_nbytes = 4 + return self._info + + def _send_start(self): + raise OSError("boom") + + src = _FailStart() + with pytest.raises(OSError): + src.connect() + assert src._sock is None + src._b.close() diff --git a/tests/test_otb_muovi_loopback.py b/tests/test_otb_muovi_loopback.py index 5b8b358..ac9072e 100644 --- a/tests/test_otb_muovi_loopback.py +++ b/tests/test_otb_muovi_loopback.py @@ -20,7 +20,7 @@ def test_muovi_loopback_emg_mode0(): # MuoviSource is the server; the fake probe is the client that dials in. src = MuoviSource(host_ip="127.0.0.1", port=0, mode=0, emg=True) - info = src.connect_listen() # bind+listen, return the bound port (test hook) + src.connect_listen() # bind + listen (does not block on accept) port = src._server.getsockname()[1] received_cmd = [] From f3a251d620547ff080cb5ae4b3a2dc6fb62ac5f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Wed, 3 Jun 2026 23:18:00 +0200 Subject: [PATCH 24/26] fix(otb): correct Quattrocento AUX/bio partition + recv error handling (Codex) Independent Codex review caught two real bugs prior reviews missed: - Quattrocento default treated the 16 AUX IN channels as biosignal and scaled them with the EMG mV factor. Default bio is now per-mode 96/192/288/384 (= streamed - 16 AUX - 8 accessory); include_aux scales AUX IN to V and emits the 8 accessory channels raw. Channel names label bio/aux/accessory correctly. - _base.read() recv() OSError now drops the socket (and flushes buffered frames) instead of polling a dead connection forever. Tests: add Quattrocento partition + AUX-scaling + biosignal-only cases, an OSError-drop case, and assert the Muovi start control byte (0x09) in the loopback. Docs updated with default biosignal counts. 25 OTB tests pass. --- docs/how-to/connect-otb-devices.md | 5 +++- myogestic/sources/otb/_base.py | 8 +++++- myogestic/sources/otb/_constants.py | 18 +++++++++--- myogestic/sources/otb/quattrocento.py | 12 ++++++-- tests/test_otb_decode.py | 41 +++++++++++++++++++++++++++ tests/test_otb_muovi_loopback.py | 14 +++++---- 6 files changed, 84 insertions(+), 14 deletions(-) diff --git a/docs/how-to/connect-otb-devices.md b/docs/how-to/connect-otb-devices.md index c9ae53e..b4df247 100644 --- a/docs/how-to/connect-otb-devices.md +++ b/docs/how-to/connect-otb-devices.md @@ -34,6 +34,9 @@ stream.start() ``` `nch_mode` 0..3 → 120/216/312/408 streamed channels; `fs_mode` 0..3 → -512/2048/5120/10240 Hz. Always stop the stream before reconnecting. +512/2048/5120/10240 Hz. Biosignal-only by default exposes the grid channels +(96/192/288/384 for nch_mode 0..3) scaled to mV; `include_aux=True` also appends +the 16 AUX IN (analog, scaled to V) and the 8 accessory channels (counter / +trigger / buffer, raw). Always stop the stream before reconnecting. > Protocol references: `docs/reference/otb/`. diff --git a/myogestic/sources/otb/_base.py b/myogestic/sources/otb/_base.py index 06487b5..edf1856 100644 --- a/myogestic/sources/otb/_base.py +++ b/myogestic/sources/otb/_base.py @@ -69,7 +69,13 @@ def read(self) -> tuple[np.ndarray | None, np.ndarray | None]: except BlockingIOError: pass except OSError: - return None, None + # Device reset / unplugged. Drop the socket (so we stop polling a + # dead connection) but still flush any whole frames already + # buffered via _drain() below. + try: + self._sock.close() + finally: + self._sock = None return self._drain() def disconnect(self) -> None: diff --git a/myogestic/sources/otb/_constants.py b/myogestic/sources/otb/_constants.py index 59f254d..ab1a767 100644 --- a/myogestic/sources/otb/_constants.py +++ b/myogestic/sources/otb/_constants.py @@ -63,6 +63,11 @@ def muovi_channel_names(geo: MuoviGeometry) -> list[str]: QUATTRO_PORT = 23456 QUATTRO_FS_BY_MODE = {0: 512.0, 1: 2048.0, 2: 5120.0, 3: 10240.0} QUATTRO_NCH_BY_MODE = {0: 120, 1: 216, 2: 312, 3: 408} +# Biosignal grid channels per mode = streamed total minus 16 AUX IN minus 8 +# accessory (Read_Quattrocento.m: NCHsel = IN.. + MULTIPLE IN.. + AUX IN). +QUATTRO_BIO_BY_MODE = {0: 96, 1: 192, 2: 288, 3: 384} +QUATTRO_N_AUX_IN = 16 # back-panel analog AUX IN (scaled to V) +QUATTRO_N_ACCESSORY = 8 # last 8: counter / trigger / buffer / reserved (raw) # Read_Quattrocento.m: GainFactor = 5/2^16/150*1000 (mV); AuxGain = 5/2^16/0.5 (V) QUATTRO_CONV_FACTOR_MV = 5 / 2 ** 16 / 150 * 1000 QUATTRO_AUX_FACTOR_V = 5 / 2 ** 16 / 0.5 @@ -105,13 +110,18 @@ def quattro_config( def quattro_channel_names(nch_total: int, n_bio: int) -> list[str]: + # Layout: [n_bio biosignal][16 AUX IN][8 accessory]. The middle AUX block is + # whatever is left between bio and the final 8 accessory channels. + n_aux = max(0, nch_total - n_bio - QUATTRO_N_ACCESSORY) names = [f"bio{i}" for i in range(n_bio)] - names += [f"ch{i}" for i in range(n_bio, nch_total)] + names += [f"aux{i}" for i in range(n_aux)] + names += [f"acc{i}" for i in range(nch_total - n_bio - n_aux)] # The 8 accessory channels are the last 8 (0-indexed names[-8:]). # Read_Quattrocento.m: counter (RampChan) = nch-7 (1-indexed) -> names[-8]; # buffer (BuffChan) = nch-4 (1-indexed) -> names[-5]; trigger is the # accessory channel between them (config protocol v1.7) -> names[-7]. - names[-8] = "counter" - names[-7] = "trigger" - names[-5] = "buffer" + if nch_total >= QUATTRO_N_ACCESSORY: + names[-8] = "counter" + names[-7] = "trigger" + names[-5] = "buffer" return names diff --git a/myogestic/sources/otb/quattrocento.py b/myogestic/sources/otb/quattrocento.py index eb7446d..6b3baee 100644 --- a/myogestic/sources/otb/quattrocento.py +++ b/myogestic/sources/otb/quattrocento.py @@ -51,7 +51,9 @@ def __init__( self._connect_timeout = connect_timeout self._nch_total = C.QUATTRO_NCH_BY_MODE[nch_mode] # default: everything except the 8 accessory channels is "bio" - self._n_bio = n_bio if n_bio is not None else self._nch_total - 8 + self._n_bio = ( + n_bio if n_bio is not None else C.QUATTRO_BIO_BY_MODE[nch_mode] + ) # --- base hooks --------------------------------------------------------- def _open(self) -> StreamInfo: @@ -90,5 +92,9 @@ def _decode(self, frame: bytes) -> np.ndarray: bio = full[:, : self._n_bio] * np.float32(C.QUATTRO_CONV_FACTOR_MV) if not self._include_aux: return bio - rest = full[:, self._n_bio :] # AUX IN + accessory, unscaled - return np.concatenate([bio, rest], axis=1).astype(np.float32) + # Layout after bio: 16 AUX IN (analog, scale to V), then 8 accessory + # (counter/trigger/buffer/reserved — raw integers, no scaling). + acc_start = self._nch_total - C.QUATTRO_N_ACCESSORY + aux_in = full[:, self._n_bio : acc_start] * np.float32(C.QUATTRO_AUX_FACTOR_V) + accessory = full[:, acc_start:] + return np.concatenate([bio, aux_in, accessory], axis=1).astype(np.float32) diff --git a/tests/test_otb_decode.py b/tests/test_otb_decode.py index 625a1ef..af00561 100644 --- a/tests/test_otb_decode.py +++ b/tests/test_otb_decode.py @@ -207,3 +207,44 @@ def _send_start(self): src.connect() assert src._sock is None src._b.close() + + +def test_quattro_default_bio_partition_and_aux_scaling(): + """Default bio excludes the 16 AUX IN + 8 accessory; AUX scaled to V, + accessory raw (Codex-flagged: AUX was previously scaled as bio).""" + from myogestic.sources.otb.quattrocento import QuattrocentoSource + + src = QuattrocentoSource(nch_mode=0, include_aux=True) # 120 total + assert src._n_bio == 96 # 120 - 16 AUX - 8 accessory + out = src._decode(_le_int16_bytes(list(range(120)))) + assert out.shape == (1, 120) + np.testing.assert_allclose(out[0, 10], 10 * C.QUATTRO_CONV_FACTOR_MV, rtol=1e-5) + np.testing.assert_allclose(out[0, 96], 96 * C.QUATTRO_AUX_FACTOR_V, rtol=1e-5) + np.testing.assert_allclose(out[0, 119], 119.0, rtol=1e-6) # accessory raw + + +def test_quattro_default_bio_excludes_aux_when_biosignal_only(): + from myogestic.sources.otb.quattrocento import QuattrocentoSource + + src = QuattrocentoSource(nch_mode=1) # 216 total, biosignal-only + assert src._n_bio == 192 + out = src._decode(_le_int16_bytes(list(range(216)))) + assert out.shape == (1, 192) + + +def test_base_read_drops_socket_on_oserror(): + """A recv() OSError (device reset) drops the socket instead of polling + a dead connection forever (Codex-flagged).""" + + class _BoomRecv: + def recv(self, _n): + raise OSError("connection reset") + + def close(self): + pass + + src = _FakeOTB() + src.connect() + src._sock = _BoomRecv() + assert src.read() == (None, None) + assert src._sock is None diff --git a/tests/test_otb_muovi_loopback.py b/tests/test_otb_muovi_loopback.py index ac9072e..205ce85 100644 --- a/tests/test_otb_muovi_loopback.py +++ b/tests/test_otb_muovi_loopback.py @@ -31,14 +31,14 @@ def fake_probe(): # daemon thread exits cleanly without printing a spurious traceback. try: c = socket.create_connection(("127.0.0.1", port), timeout=2.0) - # one sample-instant: channels 0..37 valued 0..37 - frame = _be_int16_frame(list(range(geo.n_total))) c.settimeout(2.0) - for _ in range(20): + # The source sends the control byte right after accepting, before any + # data flows — read it first so a mid-stream disconnect can't race it. + received_cmd.append(c.recv(1)) + frame = _be_int16_frame(list(range(geo.n_total))) # one sample-instant + for _ in range(40): c.sendall(frame) time.sleep(0.005) - received_cmd.append(c.recv(1)) - time.sleep(0.2) c.close() except OSError: pass @@ -66,6 +66,10 @@ def fake_probe(): # channel 0 raw was 0 -> 0 mV; channel 5 raw was 5 -> 5*0.000286 mV np.testing.assert_allclose(data[0, 5], 5 * 0.000286, rtol=1e-5) + # the probe should have received the EMG+mode0+GO control byte (0x09) + t.join(timeout=2.0) + assert received_cmd and received_cmd[0] == bytes([0x09]) + def test_muovi_source_importable_from_package(): from myogestic.sources.otb import MuoviSource as M From b15baa9762f6f1602cc219fa5dbe67464e7c0b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Thu, 4 Jun 2026 08:43:48 +0200 Subject: [PATCH 25/26] docs(spec): confirm Muovi+ direct geometry = 64 bio + 6 aux = 70 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Muovi+ is identical to Muovi but with 64 biosignal channels (same 6 aux, control byte, and big-endian format) -> 70 total / 38 for the 32-ch mode. The 68/36 figures only arise via the SyncStation path (§11), which packs channels differently. Resolves the prior "direct Muovi+ geometry unverified" note; implementation already matched (muovi_geometry(plus=True) -> 64+6=70). --- .../specs/2026-06-03-otb-device-sources-design.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md index 1d49506..08c967e 100644 --- a/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md +++ b/docs/superpowers/specs/2026-06-03-otb-device-sources-design.md @@ -137,7 +137,9 @@ imported/shipped). Quattrocento (§5.2) is **also manufacturer-verified** now - `01` = monopolar **16-ch** → **22 ch** (16 bio + 6 aux). ⚠️ Ambiguous: the v2.4 PDF labels `01` as "gain 4, 32 ch", while MATLAB v3.0 labels it "16-ch monop". Treat as firmware-dependent; **avoid in v1** unless we confirm - on the specific probe firmware. (Muovi+ analogue: `0`=64-ch→68, `1`=32-ch→36.) + on the specific probe firmware. (Muovi+ analogue, direct mode: `0`=64-ch→70, + `1`=32-ch→38 — i.e. 64/32 bio + the same 6 aux. The 68/36 figures appear only + via the SyncStation path, §11, which packs channels differently.) - `10` = impedance check → 38 ch. - `11` = test (ramps on all channels) → 38 ch. Ideal for validating decode/endianness on first connect. @@ -153,7 +155,9 @@ imported/shipped). Quattrocento (§5.2) is **also manufacturer-verified** now - **Implication for the source:** `StreamInfo.n_channels` must be derived from `(device, mode)`, not hardcoded — mode 1 changes the count. The decoder reads `NumChan` channels per sample-instant where `NumChan` = the table above. -- **Geometry:** Muovi = 32 biosignal + 6 aux = 38 total; Muovi+ = 64 + 6 = 70. +- **Geometry:** Muovi = 32 biosignal + 6 aux = 38 total; Muovi+ = 64 + 6 = 70 + (confirmed: Muovi+ is identical to Muovi but with 64 biosignal channels — same + 6 aux, same control byte, same big-endian format). | Device | Mode | total | bio | aux | Fs | bytes/sample | samples/frame | frame bytes | |---|---|---|---|---|---|---|---|---| From cfa65cf40ab035e32397d9b0616720fbb38150f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Sun, 7 Jun 2026 11:44:57 +0200 Subject: [PATCH 26/26] chore(otb): adapt to merged main (reorg + standardized API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged main (PR #8: library reorg, naming standardization, ruff D / ty-clean, Windows session fix, doc/example test harness) into the OTB device-sources branch and brought the PR up to date: - API: window_seconds= -> window_ms= in the OTB example + connect-otb-devices.md (StreamInfo/Source usage was unchanged, so the sources needed no edits). - ruff (new D + rules now apply to OTB code): package docstring for sources/otb, D400 period fix in _constants, disconnect() docstring in muovi; gave tests the same E402 import-placement latitude examples already have; dropped an unused import. - ty (repo is ty-clean now): narrowed the socket/StreamInfo Optionals in _base/muovi/quattrocento (local-bind in read(); asserts before use) — no behaviour change. - test_docs harness: handle code blocks nested in list items (indented closing fence + dedent) so connect-otb-devices.md parses. ruff + ty clean, 337 tests, docs 0 warnings. Version stays 2.0.2. --- docs/how-to/connect-otb-devices.md | 4 ++-- examples/otb/muovi_emg.py | 2 +- myogestic/sources/otb/__init__.py | 2 ++ myogestic/sources/otb/_base.py | 10 ++++++---- myogestic/sources/otb/_constants.py | 2 +- myogestic/sources/otb/muovi.py | 4 ++++ myogestic/sources/otb/quattrocento.py | 2 ++ pyproject.toml | 2 +- tests/test_docs.py | 9 ++++++++- tests/test_otb_crc.py | 2 +- tests/test_otb_muovi_loopback.py | 2 +- 11 files changed, 29 insertions(+), 12 deletions(-) diff --git a/docs/how-to/connect-otb-devices.md b/docs/how-to/connect-otb-devices.md index b4df247..775692c 100644 --- a/docs/how-to/connect-otb-devices.md +++ b/docs/how-to/connect-otb-devices.md @@ -13,7 +13,7 @@ The PC is the TCP **server**; the probe connects to it. from myogestic import Stream from myogestic.sources.otb import MuoviSource stream = Stream("emg", source=MuoviSource(plus=False, emg=True, mode=0), - window_seconds=1.0) + window_ms=1000) stream.start() ``` @@ -29,7 +29,7 @@ Give the PC NIC a `169.254.x.x` address on that segment. ```python from myogestic.sources.otb import QuattrocentoSource stream = Stream("emg", source=QuattrocentoSource(fs_mode=1, nch_mode=1), - window_seconds=1.0) # 2048 Hz, 216 streamed ch + window_ms=1000) # 2048 Hz, 216 streamed ch stream.start() ``` diff --git a/examples/otb/muovi_emg.py b/examples/otb/muovi_emg.py index c30e1c1..fd124fd 100644 --- a/examples/otb/muovi_emg.py +++ b/examples/otb/muovi_emg.py @@ -14,7 +14,7 @@ def main() -> None: stream = Stream( "emg", source=MuoviSource(plus=False, emg=True, mode=0), # 32-ch gain-8 @2000Hz - window_seconds=1.0, + window_ms=1000, ) stream.start() print("Connected. Reading 5 windows...") diff --git a/myogestic/sources/otb/__init__.py b/myogestic/sources/otb/__init__.py index 2c4f20a..ef743c3 100644 --- a/myogestic/sources/otb/__init__.py +++ b/myogestic/sources/otb/__init__.py @@ -1,3 +1,5 @@ +"""Native pure-Python sources for OTB devices (Muovi / Muovi+ & Quattrocento).""" + from myogestic.sources.otb.muovi import MuoviSource from myogestic.sources.otb.quattrocento import QuattrocentoSource diff --git a/myogestic/sources/otb/_base.py b/myogestic/sources/otb/_base.py index edf1856..b759921 100644 --- a/myogestic/sources/otb/_base.py +++ b/myogestic/sources/otb/_base.py @@ -55,16 +55,17 @@ def connect(self) -> StreamInfo: return info def read(self) -> tuple[np.ndarray | None, np.ndarray | None]: - if self._sock is not None: + sock = self._sock # local so the type checker keeps the None-narrowing + if sock is not None: try: - chunk = self._sock.recv(65536) + chunk = sock.recv(65536) if chunk: self._buf.extend(chunk) else: # Empty recv = peer closed the connection. Drop the socket so # the acquire loop stops spinning, but still flush any whole # frames already buffered (handled by _drain below). - self._sock.close() + sock.close() self._sock = None except BlockingIOError: pass @@ -73,7 +74,7 @@ def read(self) -> tuple[np.ndarray | None, np.ndarray | None]: # dead connection) but still flush any whole frames already # buffered via _drain() below. try: - self._sock.close() + sock.close() finally: self._sock = None return self._drain() @@ -95,6 +96,7 @@ def _drain(self) -> tuple[np.ndarray | None, np.ndarray | None]: """Slice all complete frames out of the buffer, decode, timestamp.""" if self._frame_nbytes <= 0 or len(self._buf) < self._frame_nbytes: return None, None + assert self._info is not None # set by connect() alongside _frame_nbytes n_frames = len(self._buf) // self._frame_nbytes take = n_frames * self._frame_nbytes raw = bytes(self._buf[:take]) diff --git a/myogestic/sources/otb/_constants.py b/myogestic/sources/otb/_constants.py index ab1a767..4cec401 100644 --- a/myogestic/sources/otb/_constants.py +++ b/myogestic/sources/otb/_constants.py @@ -46,7 +46,7 @@ def muovi_geometry(*, plus: bool, emg: bool, mode: int) -> MuoviGeometry: def muovi_control_byte(*, emg: bool, mode: int, go: bool) -> int: - """Muovi control byte: (EMG<<3) | (mode<<1) | GO. (Read_muovi.m formula.)""" + """Muovi control byte: (EMG<<3) | (mode<<1) | GO, per the Read_muovi.m formula.""" return (int(emg) << 3) | ((mode & 0x3) << 1) | int(go) diff --git a/myogestic/sources/otb/muovi.py b/myogestic/sources/otb/muovi.py index 9dd7341..dca897f 100644 --- a/myogestic/sources/otb/muovi.py +++ b/myogestic/sources/otb/muovi.py @@ -78,6 +78,7 @@ def accept_and_start(self) -> StreamInfo: Runs the base lifecycle inline (NOT via base ``connect()``) because the server socket / accept is Muovi-specific. """ + assert self._server is not None, "call connect_listen() before accept_and_start()" self._server.settimeout(self._accept_timeout) conn, _addr = self._server.accept() conn.setblocking(False) @@ -104,10 +105,12 @@ def _open(self) -> StreamInfo: ) def _send_start(self) -> None: + assert self._sock is not None cmd = C.muovi_control_byte(emg=self._emg, mode=self._mode, go=True) self._sock.sendall(bytes([cmd])) def _send_stop(self) -> None: + assert self._sock is not None cmd = C.muovi_control_byte(emg=self._emg, mode=self._mode, go=False) self._sock.sendall(bytes([cmd])) @@ -123,6 +126,7 @@ def _decode(self, frame: bytes) -> np.ndarray: return np.concatenate([bio, aux], axis=1).astype(np.float32) def disconnect(self) -> None: + """Stop streaming and close the device + listening sockets.""" super().disconnect() if self._server is not None: try: diff --git a/myogestic/sources/otb/quattrocento.py b/myogestic/sources/otb/quattrocento.py index 6b3baee..1869996 100644 --- a/myogestic/sources/otb/quattrocento.py +++ b/myogestic/sources/otb/quattrocento.py @@ -78,11 +78,13 @@ def _open(self) -> StreamInfo: ) def _send_start(self) -> None: + assert self._sock is not None cfg = C.quattro_config(fs_mode=self._fs_mode, nch_mode=self._nch_mode, acq_on=True) self._sock.sendall(cfg) def _send_stop(self) -> None: + assert self._sock is not None cfg = C.quattro_config(fs_mode=self._fs_mode, nch_mode=self._nch_mode, acq_on=False) self._sock.sendall(cfg) diff --git a/pyproject.toml b/pyproject.toml index 298c119..ba73443 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,7 +153,7 @@ ignore = [ convention = "numpy" [tool.ruff.lint.per-file-ignores] -"tests/*" = ["B011", "D"] # allow `assert`; tests don't need docstrings +"tests/*" = ["B011", "D", "E402"] # allow `assert`; tests don't need docstrings "examples/**/*" = ["E402", "D"] # imports after module-level code; examples are prose-documented # Optional-dep imports at module bottom are intentional (conditional availability) "myogestic/outputs/__init__.py" = ["E402"] diff --git a/tests/test_docs.py b/tests/test_docs.py index 2ae8e0e..614d288 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -27,6 +27,7 @@ import re import shutil import tempfile +import textwrap from pathlib import Path import numpy as np @@ -51,7 +52,9 @@ # Optional `` / `` directive on the line above a fence. _BLOCK = re.compile( - r"(?:[ \t]*\n)?```python\n(.*?)\n```", + # The closing fence may be indented (code block nested in a list item), so + # allow leading whitespace before it; textwrap.dedent then cleans the body. + r"(?:[ \t]*\n)?```python\n(.*?)\n[ \t]*```", re.DOTALL, ) @@ -61,6 +64,10 @@ def _blocks(path: Path): text = path.read_text(encoding="utf-8") for m in _BLOCK.finditer(text): directive, code = m.group(1), m.group(2) + # Dedent: code blocks nested in a list item (e.g. "3. ```python") carry + # the list's indentation, which mkdocs strips on render but ast.parse + # would choke on. + code = textwrap.dedent(code) # `--8<--` snippet includes pull real code from examples/ (the example is # import/wire-tested by tests/test_examples.py) — they aren't literal # python here, so skip both layers. diff --git a/tests/test_otb_crc.py b/tests/test_otb_crc.py index d9c806e..3b4f4a4 100644 --- a/tests/test_otb_crc.py +++ b/tests/test_otb_crc.py @@ -2,7 +2,7 @@ def test_crc8_empty_is_zero(): - assert crc8(bytes()) == 0 + assert crc8(b"") == 0 def test_crc8_matches_matlab_reference_algorithm(): diff --git a/tests/test_otb_muovi_loopback.py b/tests/test_otb_muovi_loopback.py index 205ce85..34a086b 100644 --- a/tests/test_otb_muovi_loopback.py +++ b/tests/test_otb_muovi_loopback.py @@ -4,7 +4,7 @@ import numpy as np -from myogestic.sources.otb._constants import muovi_control_byte, muovi_geometry +from myogestic.sources.otb._constants import muovi_geometry from myogestic.sources.otb.muovi import MuoviSource