Metadata-Version: 2.4
Name: bouwmeester-lockbox-api
Version: 0.1.1
Summary: Python client for the Bouwmeester Lab Lockbox REST API
License-Expression: AGPL-3.0-only
Project-URL: Homepage, https://github.com/Bouwmeester-Lab/Lockbox-PythonApi
Project-URL: Repository, https://github.com/Bouwmeester-Lab/Lockbox-PythonApi
Project-URL: Issues, https://github.com/Bouwmeester-Lab/Lockbox-PythonApi/issues
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Requires-Dist: signalrcore==1.0.2
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: ruff; extra == "dev"
Dynamic: license-file

# Lockbox Python API

Python 3.11+ synchronous controls for the Lockbox server. Install from this
directory with `python -m pip install .` (or `python -m pip install -e ".[dev]"`
for development).

```python
from lockbox import Server, SlopePreference

with Server("http://lockboxcontrol.internal:5106") as server:
    box = server.get_lockbox(1)  # Choose an ID from server.list_lockboxes().
    box.on_coarse_resonance_found(lambda: print("Coarse resonance found"))
    box.on_fine_resonance_found(lambda: print("Fine resonance found"))
    box.on_lockbox_freeze_ready(lambda: print("Ready to freeze"))

    box.set_setpoint(0.0)
    box.set_low_high_threshold(-100, 100, SlopePreference.POSITIVE)
    box.lock()
    input("Press Enter to close the client connections... ")
```

`Server` owns one HTTP client and two SignalR connections: `/Hubs/Status` for
events and `/Hubs/Run` for setpoint and threshold commands. It uses the server's
JSON hub protocol. Entering the context connects both hubs; exiting closes all
connections without changing the hardware. There are no reconnects or retries.
`Server(url, timeout=10.0)` sets HTTP timeouts and the time allowed for hub
handshake readiness and command completion, not for physical device operations.
Supply the server root URL, including any hosting path prefix, without `/api`.

`list_lockboxes()` and `get_lockbox(id)` reuse objects by ID, preserving callbacks
while refreshing their name, IP and MAC metadata. Objects are registered for
status routing automatically. `connect_to_status_hub(box)` also registers manually
created objects; repeating registration of the same object is harmless.

## Commands

All command methods return `None` after HTTP success or Run hub invocation
completion. They do not wait for a status or physical completion. Failed HTTP
responses raise `Exception(response.text)`; hub errors use the server-provided
error message. Transport errors propagate.

| Method | Argument convention |
| --- | --- |
| `set_gain(gain)` | Input gain, at least 1 |
| `set_demodulation_cutoff(frequency)` | Hz |
| `set_demodulation_phase(phase)` | Radians |
| `set_modulation_amplitude(amplitude)` | Integer DAC codes, 0–524287 |
| `set_demodulation_amplitude(amplitude)` | Server amplitude scale, 0–100 |
| `set_setpoint(setpoint)` | Native device setpoint units |
| `set_pid(p, i, d)` | Native device PID coefficients |
| `set_threshold(threshold)` | Integer reflection threshold |
| `set_low_high_threshold(low, high, slope_preference=SlopePreference.NONE)` | Integer error-signal limits; enum values `NONE`, `NEGATIVE`, `POSITIVE` |
| `set_fine_output_code(code)`, `set_coarse_output_code(code)` | Signed DAC codes, -524287–524287 |
| `lock()`, `unlock()` | Normal lock; unlock without starting a scan |
| `freeze()`, `unfreeze()` | Both toggle freeze through the same endpoint |
| `reload_configuration()` | Apply the server's saved configuration |

Setters change runtime values, not saved configuration. Validation is left to
the server, except conversion of the slope enum. Slope preference is forwarded
as the server enum value (0, 1, or 2). The current firmware `ThresholdsEndpoint`
updates limits but does not read the slope query parameter; actual slope behavior
depends on firmware support. This package does not change firmware.

The freeze endpoint may translate a firmware rejection (including not-ready 409)
into a server 500. The client reports the response it receives.

## Callbacks

Register callbacks after entering the context. Each registration stores one
zero-argument function; registering again replaces it.

| Registration | Incoming Arduino status |
| --- | --- |
| `on_coarse_resonance_found(callback)` | `CoarseResonanceFound` |
| `on_fine_resonance_found(callback)` | `FineResonanceFound` |
| `on_lock_lost(callback)` | `Unlocked` |
| `on_lockbox_frozen(callback)` | `FrozenEnabled` |
| `on_lockbox_unfrozen(callback)` | `FrozenDisabled` |
| `on_lockbox_freeze_ready(callback)` | `DriftEstimateReady` |

`on_lock_acquired` and `on_lockbox_recentering` raise `NotImplementedError`
because dedicated statuses do not exist. HTTP results never trigger callbacks.
Unregistered devices and unrelated statuses are ignored.

Functions run directly on the status receiver thread, without arguments or custom
exception recovery. They may issue commands using the separate HTTP/Run connections.
While a callback runs, it delays further status delivery: do not wait inside it
for another callback. There is no replay, ordering contract, or experiment scheduler.

## Saved configuration

Fetch an independent snapshot, edit its supported fields, and explicitly save it:

```python
from lockbox import Server, PidControllerType, SlopePreference

with Server("http://lockboxcontrol.internal:5106") as server:
    box = server.get_lockbox(1)
    config = box.get_configuration()
    config.pid.proportional_gain = 2.0
    config.pid.controller_type = PidControllerType.FILTERED_PID
    config.demodulation.set_point = 0.1
    config.lock_thresholds.out_of_lock_threshold = 80
    config.lock_thresholds.ms_without_lock = 250
    config.lock_thresholds.delock_smoothing_constant = 0.25
    config.coarse_scan.scan_min = -2000
    config.coarse_scan.scan_max = 2000
    config.fine_scan.scan_time_ms = 1500
    if config.pdh is not None:
        config.pdh.low_limit = -100.5
        config.pdh.high_limit = 100.25
        config.pdh.slope_preference = SlopePreference.POSITIVE
    box.save_configuration(config)

    # Apply only when wanted:
    box.reload_configuration()
```

`get_configuration()` reads `GET /api/LockboxConfigurations/arduino/{id}` on
every call. It returns a `LockboxConfiguration` with typed, snake_case sections:
`pid`, `demodulation`, `lock_thresholds`, `pdh`, `coarse_scan`, `fine_scan`,
`coarse_refinement`, `fine_refinement`, and top-level `modulation_amplitude`.
An absent optional PDH section is `None`. Runtime setters do not modify snapshots.
`PidControllerType` has `SIMPLE_PI` and `FILTERED_PID`; slope settings reuse
`SlopePreference.NONE`, `NEGATIVE`, and `POSITIVE`.

All lock-threshold and coarse/fine scan settings are editable. IDs, device
identity, section references, `notch_filters`, and
`filter_selections` are read-only. Filter selections expose `notch_filter_id`,
`enabled`, and the associated read-only `notch_filter` definition when present.
Snapshots update existing records only; a missing configuration raises the normal
HTTP error. They cannot create records or replace section identities.

`save_configuration(config)` returns `None` after these eight sequential writes
(seven when PDH is absent):

| Saved settings | Endpoint |
| --- | --- |
| PID coefficients, controller type, derivative cutoff | `PUT /api/PidConfigurations/{pidId}/persist` |
| Demodulation cutoff, phase, amplitude, setpoint | `PUT /api/DemodulationConfigurations/{id}/persist` |
| Modulation amplitude | `PUT /api/PidConfigurations/{pidId}/modulation` |
| All lock-threshold settings | `PUT /api/LockThresholdSettings/{id}` |
| PDH low/high limits and slope, when present | `PUT /api/PdhSettings/{id}` |
| Coarse scan limits and time | `PUT /api/ScanSettings/{coarseId}` |
| Fine scan limits and time | `PUT /api/ScanSettings/{fineId}` |
| Coarse and fine refinement | `PUT /api/PidConfigurations/{pidId}/refinement` |

Saving does not apply hardware settings or modify filters. It sends all supported
values, including unchanged ones. Fetch again before editing if another client
may have changed the saved configuration; there is no conflict detection.

The snapshot's device/server identity, required linked records, and payloads are
checked before any writes, including both required scan records. Nonfinite or
unserializable payloads are rejected before writing. PDH limits accept decimal
values and are transmitted without integer conversion. Range and ordering
validation is performed by the server. PDH is skipped only when both its section
and linked ID are absent; a linked ID with a missing section is an error.

**Saving is not atomic.** A failed response stops subsequent requests and raises
`Exception(response.text)`; earlier successful writes remain saved. There is no
rollback or retry. Other server-side validation errors can therefore leave a
partially saved configuration. The ordinary PID update endpoint is not used
because it also applies changes to hardware.

This version requires the server's resource-style PUT endpoints for
`LockThresholdSettings`, `PdhSettings`, and `ScanSettings`. There is no fallback
to the old integer-only threshold-saving endpoint. Notch-filter editing remains
excluded; definitions, selections, and enabled flags are preserved unchanged.
Runtime setters still use their existing interfaces; fractional PDH support here
applies to saved configuration.

## Development

Run `python -m pytest` and `python -m ruff check src tests` from this directory.
Tests use mock HTTP responses and hub connections; they do not operate hardware.
