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.
Files changed (43) hide show
  1. bioexperiment_lab_devices-0.1.0/LICENSE +21 -0
  2. bioexperiment_lab_devices-0.1.0/PKG-INFO +81 -0
  3. bioexperiment_lab_devices-0.1.0/README.md +51 -0
  4. bioexperiment_lab_devices-0.1.0/pyproject.toml +49 -0
  5. bioexperiment_lab_devices-0.1.0/src/lab_devices/__init__.py +140 -0
  6. bioexperiment_lab_devices-0.1.0/src/lab_devices/client.py +82 -0
  7. bioexperiment_lab_devices-0.1.0/src/lab_devices/devices/__init__.py +6 -0
  8. bioexperiment_lab_devices-0.1.0/src/lab_devices/devices/base.py +57 -0
  9. bioexperiment_lab_devices-0.1.0/src/lab_devices/devices/densitometer.py +64 -0
  10. bioexperiment_lab_devices-0.1.0/src/lab_devices/devices/pump.py +81 -0
  11. bioexperiment_lab_devices-0.1.0/src/lab_devices/devices/valve.py +34 -0
  12. bioexperiment_lab_devices-0.1.0/src/lab_devices/discovery.py +114 -0
  13. bioexperiment_lab_devices-0.1.0/src/lab_devices/errors.py +173 -0
  14. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/__init__.py +115 -0
  15. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/analyze.py +113 -0
  16. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/blocks.py +90 -0
  17. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/clock.py +22 -0
  18. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/context.py +85 -0
  19. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/control.py +74 -0
  20. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/durations.py +27 -0
  21. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/errors.py +88 -0
  22. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/evaluate.py +167 -0
  23. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/execute.py +273 -0
  24. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/expr.py +278 -0
  25. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/finalize.py +74 -0
  26. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/inputs.py +70 -0
  27. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/occupancy.py +99 -0
  28. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/persist.py +277 -0
  29. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/registry.py +208 -0
  30. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/run.py +230 -0
  31. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/runlog.py +32 -0
  32. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/serialize.py +304 -0
  33. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/state.py +56 -0
  34. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/validate.py +559 -0
  35. bioexperiment_lab_devices-0.1.0/src/lab_devices/experiment/workflow.py +42 -0
  36. bioexperiment_lab_devices-0.1.0/src/lab_devices/jobs.py +108 -0
  37. bioexperiment_lab_devices-0.1.0/src/lab_devices/models/__init__.py +66 -0
  38. bioexperiment_lab_devices-0.1.0/src/lab_devices/models/common.py +83 -0
  39. bioexperiment_lab_devices-0.1.0/src/lab_devices/models/densitometer.py +83 -0
  40. bioexperiment_lab_devices-0.1.0/src/lab_devices/models/pump.py +51 -0
  41. bioexperiment_lab_devices-0.1.0/src/lab_devices/models/valve.py +41 -0
  42. bioexperiment_lab_devices-0.1.0/src/lab_devices/py.typed +0 -0
  43. 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,6 @@
1
+ from lab_devices.devices.base import Device
2
+ from lab_devices.devices.densitometer import Densitometer
3
+ from lab_devices.devices.pump import Pump
4
+ from lab_devices.devices.valve import Valve
5
+
6
+ __all__ = ["Device", "Densitometer", "Pump", "Valve"]
@@ -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)