summaryrefslogtreecommitdiff
path: root/CLAUDE.md
blob: b95d079f8ed3fde82b1ada6eb26b0d7b68f1a7ed (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Running the app

```bash
python main.py
```

No build step. Simulation mode default (no hardware needed).

## Dependencies

```bash
pip install PyQt6 pyqtgraph numpy pyserial
pip install nidaqmx        # optional — only for real NI hardware
pip install opencv-python  # optional — only for motion capture plugin
```

## Architecture

### Data flow

```
Hardware / Simulation
        ↓
  api_layers/          ArduinoLayer, NidaqmxLayer — low-level I/O, background threads
        ↓
  devices/             BaseDevice subclasses — wrap api_layers, expose read_channels() / write_channel()
        ↓
  core/acquisition.py  AcquisitionEngine — polls devices at 100 ms, fills ChannelBuffers, emits new_data signal
        ↓
  core/signal_processor.py  SignalProcessor — filters + derived channels, re-emits processed_data
        ↓
  ui/strip_chart.py    StripChartWidget — consumes processed_data, renders via pyqtgraph
```

Control widgets (left panel) go other direction: UI → `ControlWidget._write()` → `DeviceRegistry.get_instance()` → `device.write_channel()` → api_layer.

### Key design patterns

**Shared serial port** — `api_layers/port_registry.py` holds module-level `port_registry` singleton. Multiple devices sharing one Arduino (e.g. `AnalogInputDevice` + `DigitalIODevice` on same port) both call `port_registry.get_layer(port, baud)`, get same `ArduinoLayer` instance. Never construct `ArduinoLayer` directly in device code.

**Device auto-discovery** — `DeviceRegistry._discover()` scans `devices/` with `pkgutil`, imports every module, registers any class subclassing `BaseDevice`. New device type = drop file in `devices/`, no registration needed.

**Backend switching at runtime** — `AnalogInputDevice` and `DigitalIODevice` both have `switch_backend(backend, simulate, ...)` — disconnects, reconfigures, reconnects without restart. Called from "Apply & Reconnect" in config dialog.

**Profile persistence** — `core/profile.py` serialises full operator state (controls, plot layout, signal pipelines, derived channels) to `.labui` JSON files. `ProfileManager` handles save/load.

### Directory map

| Path | Purpose |
|------|---------|
| `api_layers/arduino_layer.py` | Serial protocol + simulation; `ARDUINO_FIRMWARE` string is the uploadable sketch |
| `api_layers/nidaqmx_layer.py` | NI-DAQmx wrapper with simulation fallback |
| `api_layers/port_registry.py` | Shared `ArduinoLayer` singleton per (port, baud) |
| `devices/base_device.py` | `BaseDevice`, `ChannelConfig`, `DeviceInfo`, `DeviceStatus` |
| `devices/analog_input.py` | Analog input — NI or Arduino backend |
| `devices/digital_io.py` | Digital I/O — NI or Arduino backend |
| `devices/serial_device.py` | Generic UART device |
| `core/acquisition.py` | `AcquisitionEngine` + `ChannelBuffer` |
| `core/signal_processor.py` | Filter chain + derived/virtual channels |
| `core/profile.py` | `.labui` profile save/load |
| `ui/main_window.py` | Top-level window, toolbar, demo device init, plugin lifecycle |
| `ui/control_panel.py` | Left panel output widgets (`OnOffSwitch`, `MotorControl`, etc.) |
| `ui/strip_chart.py` | Live pyqtgraph chart, config-driven by `LayoutConfig` |
| `ui/add_device_dialog.py` | Add Device dialog; `_PANELS` dict extended by plugins |
| `ui/windows/plot_window.py` | Plot Builder: BSP tree, `LayoutCanvas` drag-drop, `LayoutConfig` |
| `ui/windows/` | Floating tool windows (Devices, Signals, Plot, Settings) |
| `ui/style_dark.qss` / `style_light.qss` | Full app theme |
| `plugins/base_plugin.py` | `LabPlugin` ABC, `PluginAction`, `PluginContext` |
| `plugins/plugin_manager.py` | Discovery, load/unload, `enabled.json` persistence |
| `plugins/motion_capture/` | Camera device plugin — adds Camera type to Add Device dialog |

### Arduino firmware

Firmware embedded as `ARDUINO_FIRMWARE` in `api_layers/arduino_layer.py`. When editing: set `N_DIG_OUT` to match digital output pins in use (default 0 — leaves pins in INPUT mode, causes inverted write behaviour). Upload via Arduino IDE.

Serial protocol: `A0:3.14,D2:1\n` stream from Arduino; `W:D7:1\n` / `P:D9:128\n` commands from PC.

### Plot layout — BSP tree

`ui/windows/plot_window.py` stores the subplot arrangement as a binary space-partition tree of plain dicts:

```
{"kind": "leaf",   "pane": <int>}
{"kind": "hsplit", "ratio": <float>, "first": <node>, "second": <node>}  # left/right
{"kind": "vsplit", "ratio": <float>, "first": <node>, "second": <node>}  # top/bottom
```

Key tree functions (all in `plot_window.py`): `_tree_insert`, `_tree_remove`, `_tree_swap`, `_tree_reindex`, `_tree_equalize_ratios`. `tree_to_grid()` converts the tree to pyqtgraph `addItem(row, col, rowspan, colspan)` coordinates. `_tree_grid_size()` returns the LCM of all split denominators — the minimum grid size that expresses all ratios as integers.

`LayoutCanvas` (also in `plot_window.py`) is the drag-and-drop tile editor. Drop zones detected in `_zone_at()`: outer 1/3 of tile = split (bisect), gap between tiles = squeeze (insert-between), center = swap. `_do_drop()` executes the tree mutation.

### Plugin system

Plugins live in `plugins/<id>/` with a `manifest.json` and entry point module. The plugin directory is added to `sys.path` on load, so intra-plugin imports use bare names (`from tracker import CameraTracker`). Cross-plugin imports use the full path (`from ui.add_device_dialog import _PANELS`).

`PluginContext` passed to `on_load(context)`:
```python
context.registry    # DeviceRegistry
context.engine      # AcquisitionEngine
context.processor   # SignalProcessor
context.main_window # MainWindow
```

Optional hooks a plugin can implement:
- `get_devices() → list[BaseDevice]` — auto-registered into acquisition engine
- `get_filter_classes() → dict` — added to `SignalProcessor` filter registry
- `get_toolbar_actions() → list[PluginAction]` — buttons inserted in main toolbar
- `get_settings_widget() → QWidget` — shown in Settings → Plugins panel
- `get_save_state() / apply_save_state(dict)` — persisted in `.labui` profiles

**Extending Add Device dialog from a plugin**: `ui/add_device_dialog._PANELS` is a module-level dict `{type_name: (PanelClass, id_prefix)}`. Plugins add/remove entries in `on_load`/`on_unload`. Each panel class needs a `build_device(device_id) → BaseDevice` method.

### Adding a new device type

1. Subclass `BaseDevice` in new file under `devices/`
2. Implement: `connect()`, `disconnect()`, `read_channels() → Dict[str, float]`, `write_channel(channel_id, value) → bool`, `get_config_widget() → QWidget`
3. Include `switch_backend()` if device supports runtime reconfiguration
4. `DeviceRegistry` discovers it automatically on next run