serial-scale-bench 0.2.0__py3-none-any.whl
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.
- serial_scale_bench/__init__.py +12 -0
- serial_scale_bench/_version.py +24 -0
- serial_scale_bench/api.py +69 -0
- serial_scale_bench/cli.py +20 -0
- serial_scale_bench/entrypoint.py +23 -0
- serial_scale_bench/py.typed +0 -0
- serial_scale_bench/scale.py +303 -0
- serial_scale_bench-0.2.0.dist-info/METADATA +134 -0
- serial_scale_bench-0.2.0.dist-info/RECORD +11 -0
- serial_scale_bench-0.2.0.dist-info/WHEEL +4 -0
- serial_scale_bench-0.2.0.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
__author__ = "Lars B. Rollik"
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from serial_scale_bench.scale import AutoReconnectSerialScale, Scale, SerialScale
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("serial-scale-bench")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "unknown"
|
|
11
|
+
|
|
12
|
+
__all__ = ["Scale", "SerialScale", "AutoReconnectSerialScale", "__version__"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI, HTTPException
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from serial_scale_bench.scale import AutoReconnectSerialScale
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScaleStatus(BaseModel):
|
|
11
|
+
scale_id: str
|
|
12
|
+
is_connected: bool
|
|
13
|
+
last_seen: datetime | None = None
|
|
14
|
+
protocol: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_api(scale: AutoReconnectSerialScale, instance_info: dict) -> FastAPI:
|
|
18
|
+
""""""
|
|
19
|
+
app = FastAPI()
|
|
20
|
+
app.state.scale = scale
|
|
21
|
+
app.state.instance_info = (
|
|
22
|
+
instance_info # TODO: instance id/name, api host/port, serial port/baud rate
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@app.get("/status", response_model=ScaleStatus)
|
|
26
|
+
def get_scale_info():
|
|
27
|
+
return ScaleStatus(
|
|
28
|
+
scale_id=app.state.instance_info["scale_id"],
|
|
29
|
+
is_connected=app.state.scale.is_connected(),
|
|
30
|
+
last_seen=app.state.scale.last_response_time,
|
|
31
|
+
protocol=app.state.scale.protocol,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@app.get("/ping")
|
|
35
|
+
def ping_scale():
|
|
36
|
+
return {"responsive": app.state.scale.is_responsive()}
|
|
37
|
+
|
|
38
|
+
@app.get("/info")
|
|
39
|
+
def get_info():
|
|
40
|
+
return {
|
|
41
|
+
"scale_id": app.state.instance_info,
|
|
42
|
+
"hostname": socket.gethostname(),
|
|
43
|
+
"port": app.state.instance_info["port"],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@app.get("/weight")
|
|
47
|
+
def read_weight():
|
|
48
|
+
if not app.state.scale.is_responsive():
|
|
49
|
+
raise HTTPException(status_code=503, detail="Scale not responsive")
|
|
50
|
+
weight = app.state.scale.get_weight()
|
|
51
|
+
if weight is None:
|
|
52
|
+
raise HTTPException(status_code=204, detail="No weight read")
|
|
53
|
+
return {"scale_id": instance_info["id"], "weight": weight}
|
|
54
|
+
|
|
55
|
+
@app.post("/tare")
|
|
56
|
+
def tare_scale():
|
|
57
|
+
if not app.state.scale.is_responsive():
|
|
58
|
+
raise HTTPException(status_code=503, detail="Scale not responsive")
|
|
59
|
+
app.state.scale.tare()
|
|
60
|
+
return {"scale_id": instance_info["id"], "action": "tared"}
|
|
61
|
+
|
|
62
|
+
@app.post("/zero")
|
|
63
|
+
def zero_scale():
|
|
64
|
+
if not app.state.scale.is_responsive():
|
|
65
|
+
raise HTTPException(status_code=503, detail="Scale not responsive")
|
|
66
|
+
app.state.scale.zero()
|
|
67
|
+
return {"scale_id": instance_info["id"], "action": "zeroed"}
|
|
68
|
+
|
|
69
|
+
return app
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def cli_parser():
|
|
6
|
+
parser = argparse.ArgumentParser(description="Start a SerialScale FastAPI service")
|
|
7
|
+
parser.add_argument("--port", type=int, default=8000, help="Port to run FastAPI server on")
|
|
8
|
+
parser.add_argument(
|
|
9
|
+
"--device",
|
|
10
|
+
type=str,
|
|
11
|
+
default=os.environ.get("SERIAL_DEVICE"),
|
|
12
|
+
help="Serial port (e.g., /dev/ttyUSB0)",
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--scale-id", type=str, default=os.environ.get("SCALE_ID"), help="Unique scale ID or name"
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument("--baudrate", type=int, default=4800, help="Serial baud rate")
|
|
18
|
+
parser.add_argument("--timeout", type=float, default=1.0, help="Serial read timeout")
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
return args
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import uvicorn
|
|
2
|
+
|
|
3
|
+
from serial_scale_bench.cli import cli_parser
|
|
4
|
+
from serial_scale_bench.scale import AutoReconnectSerialScale
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def entrypoint():
|
|
8
|
+
args = cli_parser()
|
|
9
|
+
|
|
10
|
+
scale_id = args.scale_id
|
|
11
|
+
|
|
12
|
+
_scale = AutoReconnectSerialScale(
|
|
13
|
+
port=args.device,
|
|
14
|
+
baudrate=args.baudrate,
|
|
15
|
+
timeout=args.timeout,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
print(f"Running FastAPI server for scale '{scale_id}' on port {args.port}")
|
|
19
|
+
uvicorn.run("serial_scale_bench.api:app", host="0.0.0.0", port=args.port, reload=False)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
entrypoint()
|
|
File without changes
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import statistics
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
import serial
|
|
10
|
+
from serial import SerialException
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScaleProtocols(Enum):
|
|
14
|
+
Protocol1 = {"id": 1, "format": ""}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SerialScale:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
port: str,
|
|
21
|
+
baudrate: int = 9600,
|
|
22
|
+
timeout: float = 1,
|
|
23
|
+
protocol: int = 2,
|
|
24
|
+
):
|
|
25
|
+
self.ser = serial.Serial(
|
|
26
|
+
port=port,
|
|
27
|
+
baudrate=baudrate,
|
|
28
|
+
bytesize=serial.EIGHTBITS,
|
|
29
|
+
parity=serial.PARITY_NONE,
|
|
30
|
+
stopbits=serial.STOPBITS_ONE,
|
|
31
|
+
timeout=timeout,
|
|
32
|
+
)
|
|
33
|
+
self.protocol: int | None = protocol
|
|
34
|
+
self.last_response_time: datetime | None = None
|
|
35
|
+
|
|
36
|
+
self.ser.reset_input_buffer()
|
|
37
|
+
self.ser.reset_output_buffer()
|
|
38
|
+
|
|
39
|
+
self._infer_protocol()
|
|
40
|
+
|
|
41
|
+
def _send_command(self, command: str):
|
|
42
|
+
if not self.ser.is_open:
|
|
43
|
+
raise serial.SerialException("Serial port is not open.")
|
|
44
|
+
|
|
45
|
+
if self.ser.in_waiting:
|
|
46
|
+
self.ser.reset_input_buffer()
|
|
47
|
+
|
|
48
|
+
full_command = f"{command}\r\n".encode("ascii")
|
|
49
|
+
self.ser.write(full_command)
|
|
50
|
+
logging.debug(f"Sending command: {command}")
|
|
51
|
+
|
|
52
|
+
def _read_lines(self) -> list[str]:
|
|
53
|
+
lines = []
|
|
54
|
+
|
|
55
|
+
if self.protocol == 1:
|
|
56
|
+
max_lines = 5
|
|
57
|
+
elif self.protocol == 2:
|
|
58
|
+
max_lines = 1
|
|
59
|
+
else:
|
|
60
|
+
raise NotImplementedError(f"Protocol {self.protocol} is not supported.")
|
|
61
|
+
|
|
62
|
+
for _ in range(max_lines):
|
|
63
|
+
line = self.ser.readline().decode("ascii", errors="ignore").strip()
|
|
64
|
+
if line:
|
|
65
|
+
lines.append(line)
|
|
66
|
+
return lines
|
|
67
|
+
|
|
68
|
+
def _infer_protocol(self):
|
|
69
|
+
if self.protocol:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
self._send_command("P")
|
|
73
|
+
lines = self._read_lines()
|
|
74
|
+
|
|
75
|
+
if not lines:
|
|
76
|
+
raise RuntimeError("No response from scale.")
|
|
77
|
+
|
|
78
|
+
joined = " ".join(lines)
|
|
79
|
+
if any(h in joined for h in ["GS", "No.", "Total"]):
|
|
80
|
+
self.protocol = 1
|
|
81
|
+
|
|
82
|
+
match = re.search(r"[-+]? *([\d.]+)\s*([a-zA-Z]+)", lines[0])
|
|
83
|
+
if match:
|
|
84
|
+
self.protocol = 2
|
|
85
|
+
|
|
86
|
+
if self.protocol is None:
|
|
87
|
+
raise RuntimeError(f"Unrecognized scale output: {lines}")
|
|
88
|
+
|
|
89
|
+
def get_weight(self) -> float | None:
|
|
90
|
+
""""""
|
|
91
|
+
self._send_command("P")
|
|
92
|
+
lines = self._read_lines()
|
|
93
|
+
|
|
94
|
+
if not lines:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
if self.protocol == 1:
|
|
98
|
+
for line in lines:
|
|
99
|
+
if line.startswith(("GS", "NT", "GT")):
|
|
100
|
+
return self._parse_weight_line(line)
|
|
101
|
+
elif self.protocol == 2:
|
|
102
|
+
return self._parse_weight_line(lines[0])
|
|
103
|
+
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def tare(self):
|
|
107
|
+
self._send_command("T")
|
|
108
|
+
|
|
109
|
+
def zero(self):
|
|
110
|
+
self._send_command("Z")
|
|
111
|
+
|
|
112
|
+
def set_tare_value(self, value: float):
|
|
113
|
+
self._send_command(f"T{value:.3f}")
|
|
114
|
+
|
|
115
|
+
def _parse_weight_line(self, line: str) -> float | None:
|
|
116
|
+
line = re.sub(r"^(GS|NT|GT|ST|US)?\s*", "", line)
|
|
117
|
+
match = re.search(r"[-+]?\d+\.\d+", line)
|
|
118
|
+
if match:
|
|
119
|
+
return float(match.group(0))
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def is_connected(self) -> bool:
|
|
123
|
+
return self.ser and self.ser.is_open
|
|
124
|
+
|
|
125
|
+
def is_responsive(self) -> bool:
|
|
126
|
+
try:
|
|
127
|
+
self.ser.write(b"P\r\n")
|
|
128
|
+
line = self.ser.readline()
|
|
129
|
+
if line:
|
|
130
|
+
self.last_response_time = datetime.utcnow()
|
|
131
|
+
return True
|
|
132
|
+
except serial.SerialException:
|
|
133
|
+
pass
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def close(self):
|
|
137
|
+
if self.ser.is_open:
|
|
138
|
+
self.ser.close()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AutoReconnectSerialScale:
|
|
142
|
+
def __init__(self, *args, retry_delay=2.0, **kwargs):
|
|
143
|
+
self._args = args
|
|
144
|
+
self._kwargs = kwargs
|
|
145
|
+
self._retry_delay = retry_delay
|
|
146
|
+
self._scale: SerialScale | None = None
|
|
147
|
+
self._connect()
|
|
148
|
+
|
|
149
|
+
def _connect(self):
|
|
150
|
+
while True:
|
|
151
|
+
try:
|
|
152
|
+
self._scale = SerialScale(*self._args, **self._kwargs)
|
|
153
|
+
print("SerialScale connected.")
|
|
154
|
+
return
|
|
155
|
+
except SerialException as e:
|
|
156
|
+
print(f"Serial connection failed: {e}. Retrying...")
|
|
157
|
+
time.sleep(self._retry_delay)
|
|
158
|
+
|
|
159
|
+
def _ensure_connection(self):
|
|
160
|
+
if self._scale is None or not self._scale.is_connected():
|
|
161
|
+
print("Lost connection. Attempting reconnect...")
|
|
162
|
+
self._connect()
|
|
163
|
+
|
|
164
|
+
def __getattr__(self, name):
|
|
165
|
+
"""Forward method calls to internal SerialScale, with reconnection if needed."""
|
|
166
|
+
|
|
167
|
+
def method(*args, **kwargs):
|
|
168
|
+
self._ensure_connection()
|
|
169
|
+
try:
|
|
170
|
+
return getattr(self._scale, name)(*args, **kwargs)
|
|
171
|
+
except SerialException:
|
|
172
|
+
print("Serial error during operation. Reconnecting...")
|
|
173
|
+
self._connect()
|
|
174
|
+
return getattr(self._scale, name)(*args, **kwargs)
|
|
175
|
+
|
|
176
|
+
return method
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Scale:
|
|
180
|
+
"""High-level bench-scale driver with the same interface as serial-scale-hx711.
|
|
181
|
+
|
|
182
|
+
Construction is lightweight; call start() to open the port and infer protocol.
|
|
183
|
+
This allows the same WeighingScaleBase adapter pattern used for the HX711 scale.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
serial_port: str,
|
|
189
|
+
baudrate: int = 4800,
|
|
190
|
+
timeout: float = 1.0,
|
|
191
|
+
protocol: int | None = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
self.serial_port = serial_port
|
|
194
|
+
self.baudrate = baudrate
|
|
195
|
+
self.timeout = timeout
|
|
196
|
+
self.protocol = protocol
|
|
197
|
+
self._scale: SerialScale | None = None
|
|
198
|
+
|
|
199
|
+
def start(self, timeout: float = 10.0) -> None:
|
|
200
|
+
"""Open the serial port, infer protocol, and verify the scale is responsive."""
|
|
201
|
+
deadline = time.time() + timeout
|
|
202
|
+
last_exc: Exception | None = None
|
|
203
|
+
while time.time() < deadline:
|
|
204
|
+
try:
|
|
205
|
+
if self._scale is not None:
|
|
206
|
+
self._scale.close()
|
|
207
|
+
self._scale = None
|
|
208
|
+
self._scale = SerialScale(
|
|
209
|
+
port=self.serial_port,
|
|
210
|
+
baudrate=self.baudrate,
|
|
211
|
+
timeout=self.timeout,
|
|
212
|
+
protocol=self.protocol or 2,
|
|
213
|
+
)
|
|
214
|
+
if self._scale.is_responsive():
|
|
215
|
+
logging.info(f"Bench scale ready on {self.serial_port}")
|
|
216
|
+
return
|
|
217
|
+
except (SerialException, Exception) as exc:
|
|
218
|
+
last_exc = exc
|
|
219
|
+
time.sleep(0.2)
|
|
220
|
+
raise TimeoutError(
|
|
221
|
+
f"Bench scale on {self.serial_port} did not respond within {timeout}s. "
|
|
222
|
+
f"Last error: {last_exc}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def tare(self) -> None:
|
|
226
|
+
assert self._scale is not None
|
|
227
|
+
self._scale.tare()
|
|
228
|
+
time.sleep(0.3)
|
|
229
|
+
|
|
230
|
+
def read_weight(self) -> float | None:
|
|
231
|
+
assert self._scale is not None
|
|
232
|
+
return self._scale.get_weight()
|
|
233
|
+
|
|
234
|
+
def read_weight_repeated(
|
|
235
|
+
self,
|
|
236
|
+
n_readings: int = 5,
|
|
237
|
+
inter_read_delay: float = 0.1,
|
|
238
|
+
) -> list[float]:
|
|
239
|
+
readings = []
|
|
240
|
+
for _ in range(n_readings):
|
|
241
|
+
r = self.read_weight()
|
|
242
|
+
if r is not None:
|
|
243
|
+
readings.append(r)
|
|
244
|
+
time.sleep(inter_read_delay)
|
|
245
|
+
return readings
|
|
246
|
+
|
|
247
|
+
def read_weight_reliable(
|
|
248
|
+
self,
|
|
249
|
+
n_readings: int = 5,
|
|
250
|
+
inter_read_delay: float = 0.1,
|
|
251
|
+
measure: Callable = statistics.median,
|
|
252
|
+
) -> float:
|
|
253
|
+
readings = self.read_weight_repeated(n_readings, inter_read_delay)
|
|
254
|
+
if not readings:
|
|
255
|
+
raise RuntimeError(
|
|
256
|
+
f"Bench scale on {self.serial_port} returned no valid readings "
|
|
257
|
+
f"after {n_readings} attempts."
|
|
258
|
+
)
|
|
259
|
+
return measure(readings)
|
|
260
|
+
|
|
261
|
+
def read_weight_blocking(
|
|
262
|
+
self,
|
|
263
|
+
n_valid: int = 3,
|
|
264
|
+
inter_read_delay: float = 0.2,
|
|
265
|
+
timeout: float = 30.0,
|
|
266
|
+
) -> float:
|
|
267
|
+
readings = []
|
|
268
|
+
deadline = time.time() + timeout
|
|
269
|
+
while time.time() < deadline:
|
|
270
|
+
r = self.read_weight()
|
|
271
|
+
if r is not None:
|
|
272
|
+
readings.append(r)
|
|
273
|
+
if len(readings) >= n_valid:
|
|
274
|
+
return statistics.median(readings)
|
|
275
|
+
time.sleep(inter_read_delay)
|
|
276
|
+
raise TimeoutError(
|
|
277
|
+
f"Bench scale on {self.serial_port} could not produce {n_valid} valid "
|
|
278
|
+
f"readings within {timeout}s."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def disconnect(self) -> None:
|
|
282
|
+
if self._scale is not None:
|
|
283
|
+
self._scale.close()
|
|
284
|
+
self._scale = None
|
|
285
|
+
|
|
286
|
+
def __del__(self) -> None:
|
|
287
|
+
self.disconnect()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
scale = SerialScale("/dev/ttyUSB0", protocol=2)
|
|
292
|
+
|
|
293
|
+
if scale.is_connected():
|
|
294
|
+
print("Scale is connected.")
|
|
295
|
+
if scale.is_responsive():
|
|
296
|
+
print("Scale is responsive.")
|
|
297
|
+
print("Current weight:", scale.get_weight())
|
|
298
|
+
else:
|
|
299
|
+
print("Scale is unresponsive.")
|
|
300
|
+
else:
|
|
301
|
+
print("Scale not connected.")
|
|
302
|
+
|
|
303
|
+
scale.close()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: serial-scale-bench
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python driver for RS-232/USB bench scales (Kern, Mettler-Toledo, etc.).
|
|
5
|
+
Project-URL: Homepage, https://github.com/MurineShiftWork/serial-scale-bench
|
|
6
|
+
Project-URL: Documentation, https://larsrollik.github.io/serial-scale-bench/
|
|
7
|
+
Project-URL: Issue Tracker, https://github.com/MurineShiftWork/serial-scale-bench/issues
|
|
8
|
+
Author-email: "Lars B. Rollik" <L.B.Rollik@protonmail.com>
|
|
9
|
+
License: BSD 3-Clause License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2021, Lars B. Rollik
|
|
12
|
+
All rights reserved.
|
|
13
|
+
|
|
14
|
+
Redistribution and use in source and binary forms, with or without
|
|
15
|
+
modification, are permitted provided that the following conditions are met:
|
|
16
|
+
|
|
17
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
18
|
+
list of conditions and the following disclaimer.
|
|
19
|
+
|
|
20
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
21
|
+
this list of conditions and the following disclaimer in the documentation
|
|
22
|
+
and/or other materials provided with the distribution.
|
|
23
|
+
|
|
24
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
25
|
+
contributors may be used to endorse or promote products derived from
|
|
26
|
+
this software without specific prior written permission.
|
|
27
|
+
|
|
28
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
29
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
30
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
31
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
32
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
33
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
34
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
35
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
36
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
37
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
38
|
+
License-File: LICENSE
|
|
39
|
+
Requires-Python: >=3.10
|
|
40
|
+
Requires-Dist: pyserial
|
|
41
|
+
Provides-Extra: api
|
|
42
|
+
Requires-Dist: fastapi; extra == 'api'
|
|
43
|
+
Requires-Dist: pydantic; extra == 'api'
|
|
44
|
+
Requires-Dist: uvicorn[standard]; extra == 'api'
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: commitizen; extra == 'dev'
|
|
47
|
+
Requires-Dist: pre-commit; extra == 'dev'
|
|
48
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
49
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
50
|
+
Provides-Extra: docs
|
|
51
|
+
Requires-Dist: mkdocs-material; extra == 'docs'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# serial-scale-bench
|
|
55
|
+
|
|
56
|
+
Python driver for RS-232/USB commercial bench scales (Kern, Mettler-Toledo, etc.).
|
|
57
|
+
|
|
58
|
+
Supports two ASCII response protocols:
|
|
59
|
+
- **Protocol 1** — multi-line header responses (`GS`, `NT`, `GT` prefixes); typical of
|
|
60
|
+
Kern and similar label-printing scales
|
|
61
|
+
- **Protocol 2** — single float line; minimal scales and simpler firmware
|
|
62
|
+
|
|
63
|
+
Protocol is auto-detected on first connection. A FastAPI HTTP server is included for
|
|
64
|
+
running the scale as a networked service (e.g. LabWatch integration).
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
Base (serial only):
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install serial-scale-bench
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
With HTTP API server:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install "serial-scale-bench[api]"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or editable from this repo:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install -e .
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Usage
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from serial_scale_bench import SerialScale
|
|
90
|
+
|
|
91
|
+
scale = SerialScale(port="/dev/ttyUSB0", baudrate=9600)
|
|
92
|
+
scale.tare()
|
|
93
|
+
weight = scale.get_weight() # float grams or None
|
|
94
|
+
scale.close()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Auto-reconnect wrapper
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from serial_scale_bench import AutoReconnectSerialScale
|
|
101
|
+
|
|
102
|
+
scale = AutoReconnectSerialScale(port="/dev/ttyUSB0")
|
|
103
|
+
weight = scale.get_weight() # reconnects automatically on error
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## HTTP API server
|
|
107
|
+
|
|
108
|
+
Start a local REST server exposing the scale over HTTP:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python -m serial_scale_bench.entrypoint --device /dev/ttyUSB0 --port 8080 --scale-id bench1
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Endpoints: `GET /weight`, `POST /tare`, `POST /zero`, `GET /status`, `GET /ping`.
|
|
115
|
+
|
|
116
|
+
## API reference
|
|
117
|
+
|
|
118
|
+
| Method | Description |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `get_weight()` | Poll current weight (float or None) |
|
|
121
|
+
| `tare()` | Send tare command |
|
|
122
|
+
| `zero()` | Send zero command |
|
|
123
|
+
| `set_tare_value(value)` | Set absolute tare |
|
|
124
|
+
| `is_connected()` | True if serial port is open |
|
|
125
|
+
| `is_responsive()` | True if scale responds to ping |
|
|
126
|
+
| `close()` | Close serial connection |
|
|
127
|
+
|
|
128
|
+
## murineshiftwork integration
|
|
129
|
+
|
|
130
|
+
Planned: `BenchScaleAdapter` in `murineshiftwork.logic.scale`. Install with:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pip install "murineshiftwork[calibration]"
|
|
134
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
serial_scale_bench/__init__.py,sha256=_N0FPvq7LRcS8xmlpGVaKDiARURVmAx_Mhm3EoTn8V0,365
|
|
2
|
+
serial_scale_bench/_version.py,sha256=s9U3X54Pdr-Jlh9GP6LBaa_VRos7qs_wtrVefUHHNIA,520
|
|
3
|
+
serial_scale_bench/api.py,sha256=hDVKtgUrOnMbo0oAeEni7VQRtqwR_LnDzT9Z8ORBsBs,2207
|
|
4
|
+
serial_scale_bench/cli.py,sha256=8CN3UdAyNm0d5iyV3uREU88xt52S7IojTKTNPmqlKBg,751
|
|
5
|
+
serial_scale_bench/entrypoint.py,sha256=vYBBCGV9JrikpgvqpmmNmVhEKCEP8sqQAjSGjKEQGt4,551
|
|
6
|
+
serial_scale_bench/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
serial_scale_bench/scale.py,sha256=qZQUb_EQOn1NqN76M9YOT7Q0wQhAjOsWH-xzVEMo_K4,9040
|
|
8
|
+
serial_scale_bench-0.2.0.dist-info/METADATA,sha256=p6S5epnusgkH2jQBepgHMdo17jx3Cl6mlPJKVpM7tWY,4655
|
|
9
|
+
serial_scale_bench-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
serial_scale_bench-0.2.0.dist-info/licenses/LICENSE,sha256=Ew8cEKZj5F51f1JVCmPZAESmeIe0HCdWdlo_NVKD-8s,1522
|
|
11
|
+
serial_scale_bench-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021, Lars B. Rollik
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|