bioexperiment-lab-devices 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bioexperiment_lab_devices-0.1.0/LICENSE +21 -0
- bioexperiment_lab_devices-0.1.0/PKG-INFO +81 -0
- bioexperiment_lab_devices-0.1.0/README.md +51 -0
- bioexperiment_lab_devices-0.1.0/pyproject.toml +49 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/__init__.py +140 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/client.py +82 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/devices/__init__.py +6 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/devices/base.py +57 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/devices/densitometer.py +64 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/devices/pump.py +81 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/devices/valve.py +34 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/discovery.py +114 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/errors.py +173 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/__init__.py +115 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/analyze.py +113 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/blocks.py +90 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/clock.py +22 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/context.py +85 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/control.py +74 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/durations.py +27 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/errors.py +88 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/evaluate.py +167 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/execute.py +273 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/expr.py +278 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/finalize.py +74 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/inputs.py +70 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/occupancy.py +99 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/persist.py +277 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/registry.py +208 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/run.py +230 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/runlog.py +32 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/serialize.py +304 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/state.py +56 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/validate.py +559 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/workflow.py +42 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/jobs.py +108 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/models/__init__.py +66 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/models/common.py +83 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/models/densitometer.py +83 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/models/pump.py +51 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/models/valve.py +41 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/py.typed +0 -0
- bioexperiment_lab_devices-0.1.0/src/lab_devices/transport.py +131 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 khamitovdr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bioexperiment-lab-devices
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python library to discover and manage lab devices (pump, valve, densitometer).
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: lab,automation,devices,async,httpx
|
|
8
|
+
Author: khamitovdr
|
|
9
|
+
Author-email: khamitov.personal@gmail.com
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Framework :: AsyncIO
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: httpx (>=0.27)
|
|
21
|
+
Requires-Dist: mypy (>=1.8) ; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest (>=8) ; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio (>=0.23) ; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff (>=0.4) ; extra == "dev"
|
|
25
|
+
Project-URL: Homepage, https://github.com/bioexperiment-lab-devices/lab-devices
|
|
26
|
+
Project-URL: Issues, https://github.com/bioexperiment-lab-devices/lab-devices/issues
|
|
27
|
+
Project-URL: Repository, https://github.com/bioexperiment-lab-devices/lab-devices
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# lab_devices
|
|
31
|
+
|
|
32
|
+
Async Python library to discover and manage lab devices — peristaltic pumps,
|
|
33
|
+
distribution valves, and densitometers — over the SerialHop / lab-bridge API.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
pip install bioexperiment-lab-devices # import as: import lab_devices
|
|
38
|
+
|
|
39
|
+
For development (runtime + dev deps):
|
|
40
|
+
|
|
41
|
+
poetry install --all-extras
|
|
42
|
+
# or, without Poetry:
|
|
43
|
+
pip install -e ".[dev]"
|
|
44
|
+
|
|
45
|
+
## Core usage (host + port)
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
from lab_devices import LabClient
|
|
50
|
+
|
|
51
|
+
async def main():
|
|
52
|
+
async with LabClient("chisel", 8089) as lab:
|
|
53
|
+
pump = lab.pump(1)
|
|
54
|
+
job = await pump.dispense(volume_ml=10, speed_ml_min=3.0)
|
|
55
|
+
result = await job.result()
|
|
56
|
+
print(result.dispensed_ml)
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Server-only discovery (inside labnet)
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from lab_devices.discovery import LabRegistry
|
|
65
|
+
|
|
66
|
+
async with LabRegistry() as reg: # LAB_DEVICES_DISCOVERY_URL overrides the endpoint
|
|
67
|
+
print(await reg.list_labs())
|
|
68
|
+
lab = await reg.connect("khamit_desktop")
|
|
69
|
+
async with lab:
|
|
70
|
+
await lab.densitometer(1).measure()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Discovery uses the internal, unauthenticated roster endpoint and needs no token.
|
|
74
|
+
It only works from inside the lab-bridge network.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
poetry run pytest # hermetic; no hardware needed
|
|
79
|
+
poetry run mypy
|
|
80
|
+
poetry run ruff check .
|
|
81
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# lab_devices
|
|
2
|
+
|
|
3
|
+
Async Python library to discover and manage lab devices — peristaltic pumps,
|
|
4
|
+
distribution valves, and densitometers — over the SerialHop / lab-bridge API.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
pip install bioexperiment-lab-devices # import as: import lab_devices
|
|
9
|
+
|
|
10
|
+
For development (runtime + dev deps):
|
|
11
|
+
|
|
12
|
+
poetry install --all-extras
|
|
13
|
+
# or, without Poetry:
|
|
14
|
+
pip install -e ".[dev]"
|
|
15
|
+
|
|
16
|
+
## Core usage (host + port)
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import asyncio
|
|
20
|
+
from lab_devices import LabClient
|
|
21
|
+
|
|
22
|
+
async def main():
|
|
23
|
+
async with LabClient("chisel", 8089) as lab:
|
|
24
|
+
pump = lab.pump(1)
|
|
25
|
+
job = await pump.dispense(volume_ml=10, speed_ml_min=3.0)
|
|
26
|
+
result = await job.result()
|
|
27
|
+
print(result.dispensed_ml)
|
|
28
|
+
|
|
29
|
+
asyncio.run(main())
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Server-only discovery (inside labnet)
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from lab_devices.discovery import LabRegistry
|
|
36
|
+
|
|
37
|
+
async with LabRegistry() as reg: # LAB_DEVICES_DISCOVERY_URL overrides the endpoint
|
|
38
|
+
print(await reg.list_labs())
|
|
39
|
+
lab = await reg.connect("khamit_desktop")
|
|
40
|
+
async with lab:
|
|
41
|
+
await lab.densitometer(1).measure()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Discovery uses the internal, unauthenticated roster endpoint and needs no token.
|
|
45
|
+
It only works from inside the lab-bridge network.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
poetry run pytest # hermetic; no hardware needed
|
|
50
|
+
poetry run mypy
|
|
51
|
+
poetry run ruff check .
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core>=2.1"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bioexperiment-lab-devices"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async Python library to discover and manage lab devices (pump, valve, densitometer)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "khamitovdr", email = "khamitov.personal@gmail.com" }]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
keywords = ["lab", "automation", "devices", "async", "httpx"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Framework :: AsyncIO",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["httpx>=0.27"]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "mypy>=1.8", "ruff>=0.4"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/bioexperiment-lab-devices/lab-devices"
|
|
32
|
+
Repository = "https://github.com/bioexperiment-lab-devices/lab-devices"
|
|
33
|
+
Issues = "https://github.com/bioexperiment-lab-devices/lab-devices/issues"
|
|
34
|
+
|
|
35
|
+
[tool.poetry]
|
|
36
|
+
packages = [{ include = "lab_devices", from = "src" }]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
asyncio_mode = "auto"
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
python_version = "3.11"
|
|
44
|
+
strict = true
|
|
45
|
+
files = ["src/lab_devices"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = "py311"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Async library to discover and manage lab devices (pump, valve, densitometer).
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
from lab_devices import LabClient
|
|
6
|
+
|
|
7
|
+
async with LabClient("chisel", 8089) as lab:
|
|
8
|
+
job = await lab.pump(1).dispense(volume_ml=10, speed_ml_min=3.0)
|
|
9
|
+
result = await job.result()
|
|
10
|
+
|
|
11
|
+
Server-only discovery (inside labnet):
|
|
12
|
+
|
|
13
|
+
from lab_devices.discovery import LabRegistry
|
|
14
|
+
|
|
15
|
+
async with LabRegistry() as reg:
|
|
16
|
+
lab = await reg.connect("khamit_desktop")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
22
|
+
|
|
23
|
+
from lab_devices.client import LabClient
|
|
24
|
+
from lab_devices.devices import Densitometer, Device, Pump, Valve
|
|
25
|
+
from lab_devices.errors import (
|
|
26
|
+
BusyError,
|
|
27
|
+
ClientLookupEndpointError,
|
|
28
|
+
ClientLookupEndpointUnreachable,
|
|
29
|
+
DeviceUnreachableError,
|
|
30
|
+
DiscoveryError,
|
|
31
|
+
DiscoveryFailedError,
|
|
32
|
+
DiscoveryInProgressError,
|
|
33
|
+
HardwareError,
|
|
34
|
+
InternalDeviceError,
|
|
35
|
+
InvalidParamsError,
|
|
36
|
+
InvalidRequestError,
|
|
37
|
+
JobCancelledError,
|
|
38
|
+
JobFailedError,
|
|
39
|
+
JobInProgressError,
|
|
40
|
+
JobTimeoutError,
|
|
41
|
+
LabDevicesError,
|
|
42
|
+
LabError,
|
|
43
|
+
LabOffline,
|
|
44
|
+
LabProtocolError,
|
|
45
|
+
NotCalibratedError,
|
|
46
|
+
NotHomedError,
|
|
47
|
+
UnknownCommandError,
|
|
48
|
+
UnknownDeviceError,
|
|
49
|
+
UnknownLabClient,
|
|
50
|
+
)
|
|
51
|
+
from lab_devices.jobs import Job, PumpJob
|
|
52
|
+
from lab_devices.models import (
|
|
53
|
+
AgentInfo,
|
|
54
|
+
Calibration,
|
|
55
|
+
CalibrationRunResult,
|
|
56
|
+
DensitometerCapabilities,
|
|
57
|
+
DensitometerStatus,
|
|
58
|
+
DeviceInfo,
|
|
59
|
+
DispenseResult,
|
|
60
|
+
Identify,
|
|
61
|
+
MeasureResult,
|
|
62
|
+
PingResult,
|
|
63
|
+
PumpCapabilities,
|
|
64
|
+
PumpStatus,
|
|
65
|
+
Range,
|
|
66
|
+
RawModel,
|
|
67
|
+
ReadRawResult,
|
|
68
|
+
Reading,
|
|
69
|
+
ReadingsResult,
|
|
70
|
+
Thermostat,
|
|
71
|
+
ThermostatState,
|
|
72
|
+
ValveCapabilities,
|
|
73
|
+
ValveConfig,
|
|
74
|
+
ValveMoveResult,
|
|
75
|
+
ValveStatus,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
__version__ = _version("bioexperiment-lab-devices")
|
|
80
|
+
except PackageNotFoundError: # pragma: no cover - running from an uninstalled source tree
|
|
81
|
+
__version__ = "0.0.0.dev0"
|
|
82
|
+
|
|
83
|
+
# NOTE: kept as a plain sorted literal list (rather than `sorted([...])`) so ruff's
|
|
84
|
+
# re-export detection (F401) recognizes every name below as intentionally exported.
|
|
85
|
+
__all__ = [
|
|
86
|
+
"AgentInfo",
|
|
87
|
+
"BusyError",
|
|
88
|
+
"Calibration",
|
|
89
|
+
"CalibrationRunResult",
|
|
90
|
+
"ClientLookupEndpointError",
|
|
91
|
+
"ClientLookupEndpointUnreachable",
|
|
92
|
+
"Densitometer",
|
|
93
|
+
"DensitometerCapabilities",
|
|
94
|
+
"DensitometerStatus",
|
|
95
|
+
"Device",
|
|
96
|
+
"DeviceInfo",
|
|
97
|
+
"DeviceUnreachableError",
|
|
98
|
+
"DiscoveryError",
|
|
99
|
+
"DiscoveryFailedError",
|
|
100
|
+
"DiscoveryInProgressError",
|
|
101
|
+
"DispenseResult",
|
|
102
|
+
"HardwareError",
|
|
103
|
+
"Identify",
|
|
104
|
+
"InternalDeviceError",
|
|
105
|
+
"InvalidParamsError",
|
|
106
|
+
"InvalidRequestError",
|
|
107
|
+
"Job",
|
|
108
|
+
"JobCancelledError",
|
|
109
|
+
"JobFailedError",
|
|
110
|
+
"JobInProgressError",
|
|
111
|
+
"JobTimeoutError",
|
|
112
|
+
"LabClient",
|
|
113
|
+
"LabDevicesError",
|
|
114
|
+
"LabError",
|
|
115
|
+
"LabOffline",
|
|
116
|
+
"LabProtocolError",
|
|
117
|
+
"MeasureResult",
|
|
118
|
+
"NotCalibratedError",
|
|
119
|
+
"NotHomedError",
|
|
120
|
+
"PingResult",
|
|
121
|
+
"Pump",
|
|
122
|
+
"PumpCapabilities",
|
|
123
|
+
"PumpJob",
|
|
124
|
+
"PumpStatus",
|
|
125
|
+
"Range",
|
|
126
|
+
"RawModel",
|
|
127
|
+
"ReadRawResult",
|
|
128
|
+
"Reading",
|
|
129
|
+
"ReadingsResult",
|
|
130
|
+
"Thermostat",
|
|
131
|
+
"ThermostatState",
|
|
132
|
+
"UnknownCommandError",
|
|
133
|
+
"UnknownDeviceError",
|
|
134
|
+
"UnknownLabClient",
|
|
135
|
+
"Valve",
|
|
136
|
+
"ValveCapabilities",
|
|
137
|
+
"ValveConfig",
|
|
138
|
+
"ValveMoveResult",
|
|
139
|
+
"ValveStatus",
|
|
140
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""LabClient — core entry point for one lab. See spec §4.1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Self
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from lab_devices.devices.base import Device
|
|
10
|
+
from lab_devices.devices.densitometer import Densitometer
|
|
11
|
+
from lab_devices.devices.pump import Pump
|
|
12
|
+
from lab_devices.devices.valve import Valve
|
|
13
|
+
from lab_devices.models import AgentInfo, DeviceInfo
|
|
14
|
+
from lab_devices.transport import Transport
|
|
15
|
+
|
|
16
|
+
_PREFIX_TO_CLASS: dict[str, type[Device]] = {
|
|
17
|
+
"pump": Pump,
|
|
18
|
+
"valve": Valve,
|
|
19
|
+
"densitometer": Densitometer,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LabClient:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
host: str,
|
|
27
|
+
port: int,
|
|
28
|
+
*,
|
|
29
|
+
request_timeout: float = 10.0,
|
|
30
|
+
discover_timeout: float = 30.0,
|
|
31
|
+
http: httpx.AsyncClient | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.host = host
|
|
34
|
+
self.port = port
|
|
35
|
+
self._owns_http = http is None
|
|
36
|
+
self._http = http or httpx.AsyncClient(
|
|
37
|
+
base_url=f"http://{host}:{port}", timeout=request_timeout
|
|
38
|
+
)
|
|
39
|
+
self._transport = Transport(self._http, discover_timeout=discover_timeout)
|
|
40
|
+
|
|
41
|
+
async def __aenter__(self) -> Self:
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
45
|
+
await self.aclose()
|
|
46
|
+
|
|
47
|
+
async def aclose(self) -> None:
|
|
48
|
+
if self._owns_http:
|
|
49
|
+
await self._http.aclose()
|
|
50
|
+
|
|
51
|
+
# ---- device handles (lazy) ----
|
|
52
|
+
def pump(self, n: int) -> Pump:
|
|
53
|
+
return Pump(self._transport, f"pump_{n}")
|
|
54
|
+
|
|
55
|
+
def valve(self, n: int) -> Valve:
|
|
56
|
+
return Valve(self._transport, f"valve_{n}")
|
|
57
|
+
|
|
58
|
+
def densitometer(self, n: int) -> Densitometer:
|
|
59
|
+
return Densitometer(self._transport, f"densitometer_{n}")
|
|
60
|
+
|
|
61
|
+
def device(self, device_id: str) -> Device:
|
|
62
|
+
prefix = device_id.rsplit("_", 1)[0]
|
|
63
|
+
cls = _PREFIX_TO_CLASS.get(prefix)
|
|
64
|
+
if cls is None:
|
|
65
|
+
raise ValueError(f"unrecognized device id prefix: {device_id!r}")
|
|
66
|
+
return cls(self._transport, device_id)
|
|
67
|
+
|
|
68
|
+
# ---- enumeration & lifecycle ----
|
|
69
|
+
async def list_devices(self) -> list[DeviceInfo]:
|
|
70
|
+
body = await self._transport.get_devices()
|
|
71
|
+
return [DeviceInfo.from_raw(d) for d in body.get("devices", [])]
|
|
72
|
+
|
|
73
|
+
async def rediscover(self) -> list[DeviceInfo]:
|
|
74
|
+
body = await self._transport.discover()
|
|
75
|
+
return [DeviceInfo.from_raw(d) for d in body.get("devices", [])]
|
|
76
|
+
|
|
77
|
+
async def disconnect(self, port: str | None = None) -> int:
|
|
78
|
+
body = await self._transport.disconnect(port)
|
|
79
|
+
return int(body.get("released", 0))
|
|
80
|
+
|
|
81
|
+
async def agent_info(self) -> AgentInfo:
|
|
82
|
+
return AgentInfo.from_raw(await self._transport.agent_info())
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Base device: universal commands shared by every device type. See spec §4.2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from lab_devices.jobs import Job
|
|
8
|
+
from lab_devices.models import Identify, PingResult, RawModel
|
|
9
|
+
from lab_devices.transport import Transport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Device:
|
|
13
|
+
STATUS_MODEL: ClassVar[type[RawModel] | None] = None
|
|
14
|
+
CAPABILITIES_MODEL: ClassVar[type[RawModel] | None] = None
|
|
15
|
+
|
|
16
|
+
def __init__(self, transport: Transport, device_id: str) -> None:
|
|
17
|
+
self._transport = transport
|
|
18
|
+
self.id = device_id
|
|
19
|
+
self.type = device_id.rsplit("_", 1)[0]
|
|
20
|
+
|
|
21
|
+
async def ping(self) -> PingResult:
|
|
22
|
+
return PingResult.from_raw(await self._transport.command(self.id, "ping"))
|
|
23
|
+
|
|
24
|
+
async def status(self) -> Any:
|
|
25
|
+
result = await self._transport.command(self.id, "status")
|
|
26
|
+
if self.STATUS_MODEL is not None and isinstance(result, dict):
|
|
27
|
+
return self.STATUS_MODEL.from_raw(result)
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
async def identify(self) -> Identify:
|
|
31
|
+
result = await self._transport.command(self.id, "identify")
|
|
32
|
+
identify = Identify.from_raw(result if isinstance(result, dict) else {})
|
|
33
|
+
if self.CAPABILITIES_MODEL is not None and isinstance(result, dict):
|
|
34
|
+
identify.capabilities = self.CAPABILITIES_MODEL.from_raw(result.get("capabilities"))
|
|
35
|
+
return identify
|
|
36
|
+
|
|
37
|
+
async def stop(self) -> Any:
|
|
38
|
+
return await self._transport.command(self.id, "stop")
|
|
39
|
+
|
|
40
|
+
async def get_job(self, job_id: str) -> Job:
|
|
41
|
+
data = await self._transport.command(self.id, "get_job", {"job_id": job_id})
|
|
42
|
+
return Job(self, job_id, data=data if isinstance(data, dict) else None)
|
|
43
|
+
|
|
44
|
+
async def command(self, cmd: str, params: dict[str, Any] | None = None) -> Any:
|
|
45
|
+
"""Raw escape hatch for any command not wrapped by a typed method."""
|
|
46
|
+
return await self._transport.command(self.id, cmd, params)
|
|
47
|
+
|
|
48
|
+
async def _start_job(
|
|
49
|
+
self,
|
|
50
|
+
cmd: str,
|
|
51
|
+
params: dict[str, Any] | None = None,
|
|
52
|
+
*,
|
|
53
|
+
result_model: type[RawModel] | None = None,
|
|
54
|
+
job_cls: type[Job] = Job,
|
|
55
|
+
) -> Job:
|
|
56
|
+
result = await self._transport.command(self.id, cmd, params)
|
|
57
|
+
return job_cls.from_start_result(self, result, result_model=result_model)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Densitometer. See spec §3.8."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from lab_devices.devices.base import Device
|
|
8
|
+
from lab_devices.jobs import Job
|
|
9
|
+
from lab_devices.models.common import RawModel
|
|
10
|
+
from lab_devices.models.densitometer import (
|
|
11
|
+
DensitometerCapabilities,
|
|
12
|
+
DensitometerStatus,
|
|
13
|
+
MeasureResult,
|
|
14
|
+
ReadingsResult,
|
|
15
|
+
ReadRawResult,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Densitometer(Device):
|
|
20
|
+
STATUS_MODEL: ClassVar[type[RawModel] | None] = DensitometerStatus
|
|
21
|
+
CAPABILITIES_MODEL: ClassVar[type[RawModel] | None] = DensitometerCapabilities
|
|
22
|
+
|
|
23
|
+
async def measure_blank(self) -> Job:
|
|
24
|
+
return await self._start_job("measure_blank")
|
|
25
|
+
|
|
26
|
+
async def measure(self, *, include_raw: bool = False) -> Job:
|
|
27
|
+
params = {"include_raw": include_raw} if include_raw else None
|
|
28
|
+
return await self._start_job("measure", params, result_model=MeasureResult)
|
|
29
|
+
|
|
30
|
+
async def start_monitoring(self, *, interval_s: float | None = None) -> Any:
|
|
31
|
+
params = {"interval_s": interval_s} if interval_s is not None else None
|
|
32
|
+
return await self.command("start_monitoring", params)
|
|
33
|
+
|
|
34
|
+
async def get_readings(
|
|
35
|
+
self, *, since_seq: int | None = None, limit: int | None = None
|
|
36
|
+
) -> ReadingsResult:
|
|
37
|
+
params: dict[str, Any] = {}
|
|
38
|
+
if since_seq is not None:
|
|
39
|
+
params["since_seq"] = since_seq
|
|
40
|
+
if limit is not None:
|
|
41
|
+
params["limit"] = limit
|
|
42
|
+
return ReadingsResult.from_raw(await self.command("get_readings", params or None))
|
|
43
|
+
|
|
44
|
+
async def stop_monitoring(self) -> Any:
|
|
45
|
+
return await self.command("stop_monitoring")
|
|
46
|
+
|
|
47
|
+
async def set_thermostat(self, *, enabled: bool, target_c: float | None = None) -> Any:
|
|
48
|
+
params: dict[str, Any] = {"enabled": enabled}
|
|
49
|
+
if target_c is not None:
|
|
50
|
+
params["target_c"] = target_c
|
|
51
|
+
return await self.command("set_thermostat", params)
|
|
52
|
+
|
|
53
|
+
async def set_tube_correction(self, *, factor: float) -> Any:
|
|
54
|
+
return await self.command("set_tube_correction", {"factor": factor})
|
|
55
|
+
|
|
56
|
+
async def calibrate_tube(self, *, reference_absorbance: float) -> Any:
|
|
57
|
+
return await self.command("calibrate_tube", {"reference_absorbance": reference_absorbance})
|
|
58
|
+
|
|
59
|
+
async def set_led(self, *, level: int) -> Any:
|
|
60
|
+
return await self.command("set_led", {"level": level})
|
|
61
|
+
|
|
62
|
+
async def read_raw(self, *, level: int | None = None) -> Job:
|
|
63
|
+
params = {"level": level} if level is not None else None
|
|
64
|
+
return await self._start_job("read_raw", params, result_model=ReadRawResult)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Peristaltic pump. See spec §3.6."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from lab_devices.devices.base import Device
|
|
8
|
+
from lab_devices.jobs import Job, PumpJob
|
|
9
|
+
from lab_devices.models.common import RawModel
|
|
10
|
+
from lab_devices.models.pump import (
|
|
11
|
+
Calibration,
|
|
12
|
+
CalibrationRunResult,
|
|
13
|
+
DispenseResult,
|
|
14
|
+
PumpCapabilities,
|
|
15
|
+
PumpStatus,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Pump(Device):
|
|
20
|
+
STATUS_MODEL: ClassVar[type[RawModel] | None] = PumpStatus
|
|
21
|
+
CAPABILITIES_MODEL: ClassVar[type[RawModel] | None] = PumpCapabilities
|
|
22
|
+
|
|
23
|
+
async def rotate(self, *, direction: str, speed_ml_min: float) -> Any:
|
|
24
|
+
return await self.command(
|
|
25
|
+
"rotate", {"direction": direction, "speed_ml_min": speed_ml_min}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
async def rotate_raw(self, *, direction: str, speed_pct: float) -> Any:
|
|
29
|
+
return await self.command("rotate_raw", {"direction": direction, "speed_pct": speed_pct})
|
|
30
|
+
|
|
31
|
+
async def dispense(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
volume_ml: float,
|
|
35
|
+
speed_ml_min: float | None = None,
|
|
36
|
+
direction: str = "forward",
|
|
37
|
+
drop_suckback_ml: float | None = None,
|
|
38
|
+
speed_profile: dict[str, Any] | None = None,
|
|
39
|
+
) -> PumpJob:
|
|
40
|
+
params: dict[str, Any] = {"direction": direction, "volume_ml": volume_ml}
|
|
41
|
+
if speed_ml_min is not None:
|
|
42
|
+
params["speed_ml_min"] = speed_ml_min
|
|
43
|
+
if drop_suckback_ml is not None:
|
|
44
|
+
params["drop_suckback_ml"] = drop_suckback_ml
|
|
45
|
+
if speed_profile is not None:
|
|
46
|
+
params["speed_profile"] = speed_profile
|
|
47
|
+
job = await self._start_job(
|
|
48
|
+
"dispense", params, result_model=DispenseResult, job_cls=PumpJob
|
|
49
|
+
)
|
|
50
|
+
return job # type: ignore[return-value]
|
|
51
|
+
|
|
52
|
+
async def pause(self) -> Any:
|
|
53
|
+
return await self.command("pause")
|
|
54
|
+
|
|
55
|
+
async def resume(self) -> Any:
|
|
56
|
+
return await self.command("resume")
|
|
57
|
+
|
|
58
|
+
async def start_calibration(self, *, speed_pct: float | None = None) -> Job:
|
|
59
|
+
params = {"speed_pct": speed_pct} if speed_pct is not None else None
|
|
60
|
+
return await self._start_job(
|
|
61
|
+
"start_calibration", params, result_model=CalibrationRunResult
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def set_calibration(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
job_id: str | None = None,
|
|
68
|
+
measured_volume_ml: float | None = None,
|
|
69
|
+
ml_per_step: float | None = None,
|
|
70
|
+
) -> Any:
|
|
71
|
+
params: dict[str, Any] = {}
|
|
72
|
+
if job_id is not None:
|
|
73
|
+
params["job_id"] = job_id
|
|
74
|
+
if measured_volume_ml is not None:
|
|
75
|
+
params["measured_volume_ml"] = measured_volume_ml
|
|
76
|
+
if ml_per_step is not None:
|
|
77
|
+
params["ml_per_step"] = ml_per_step
|
|
78
|
+
return await self.command("set_calibration", params)
|
|
79
|
+
|
|
80
|
+
async def get_calibration(self) -> Calibration:
|
|
81
|
+
return Calibration.from_raw(await self.command("get_calibration"))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Distribution valve. See spec §3.7."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from lab_devices.devices.base import Device
|
|
8
|
+
from lab_devices.jobs import Job
|
|
9
|
+
from lab_devices.models.common import RawModel
|
|
10
|
+
from lab_devices.models.valve import ValveCapabilities, ValveMoveResult, ValveStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Valve(Device):
|
|
14
|
+
STATUS_MODEL: ClassVar[type[RawModel] | None] = ValveStatus
|
|
15
|
+
CAPABILITIES_MODEL: ClassVar[type[RawModel] | None] = ValveCapabilities
|
|
16
|
+
|
|
17
|
+
async def home(self, *, position: int) -> Any:
|
|
18
|
+
return await self.command("home", {"position": position})
|
|
19
|
+
|
|
20
|
+
async def set_position(self, *, position: int, rotation: str | None = None) -> Job:
|
|
21
|
+
params: dict[str, Any] = {"position": position}
|
|
22
|
+
if rotation is not None:
|
|
23
|
+
params["rotation"] = rotation
|
|
24
|
+
return await self._start_job("set_position", params, result_model=ValveMoveResult)
|
|
25
|
+
|
|
26
|
+
async def configure(
|
|
27
|
+
self, *, default_rotation: str | None = None, hold_torque: bool | None = None
|
|
28
|
+
) -> Any:
|
|
29
|
+
params: dict[str, Any] = {}
|
|
30
|
+
if default_rotation is not None:
|
|
31
|
+
params["default_rotation"] = default_rotation
|
|
32
|
+
if hold_torque is not None:
|
|
33
|
+
params["hold_torque"] = hold_torque
|
|
34
|
+
return await self.command("configure", params)
|