labmcp-sartorius 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.
- labmcp_sartorius-0.1.0/.gitignore +15 -0
- labmcp_sartorius-0.1.0/PKG-INFO +116 -0
- labmcp_sartorius-0.1.0/README.md +104 -0
- labmcp_sartorius-0.1.0/pyproject.toml +34 -0
- labmcp_sartorius-0.1.0/server.json +46 -0
- labmcp_sartorius-0.1.0/src/labmcp_sartorius/__init__.py +1 -0
- labmcp_sartorius-0.1.0/src/labmcp_sartorius/driver.py +241 -0
- labmcp_sartorius-0.1.0/src/labmcp_sartorius/server.py +293 -0
- labmcp_sartorius-0.1.0/src/labmcp_sartorius/simulator.py +127 -0
- labmcp_sartorius-0.1.0/tests/test_server.py +242 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: labmcp-sartorius
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Sartorius laboratory balances (SBI protocol): weigh, tare, zero, log drift, internal adjustment.
|
|
5
|
+
Project-URL: Homepage, https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/sartorius-balance
|
|
6
|
+
Author: K-Dense and LabMCP contributors
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Keywords: balance,lab-instrument,mcp,sartorius,sbi,weighing
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: labmcp<0.2,>=0.1
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Sartorius Balance โ MCP Server
|
|
14
|
+
|
|
15
|
+
<!-- mcp-name: io.github.K-Dense-AI/labmcp-sartorius -->
|
|
16
|
+
|
|
17
|
+
Let an AI agent weigh samples, tare containers, log drift and evaporation, and run internal adjustments on **Sartorius laboratory balances** through the documented **SBI** (Sartorius Balance Interface) ASCII protocol.
|
|
18
|
+
|
|
19
|
+
| | |
|
|
20
|
+
|---|---|
|
|
21
|
+
| **Package** | `labmcp-sartorius` |
|
|
22
|
+
| **Instruments** | Cubis MSE, Cubis II MCA, Secura, Quintix, Practum, Entris II; older CP/CPA-series balances with `--option legacy=true`. Other SBI balances are likely to work |
|
|
23
|
+
| **Interfaces** | RS-232, USB (virtual COM port, "PC-SBI"), Ethernet (Cubis II "serial transmission via Ethernet", or any serial-to-Ethernet adapter) |
|
|
24
|
+
| **Protocol** | SBI: [Entris II interface description](https://www.sartorius.hr/media/dypfvdsn/entris-ii-technical-note-en-sartorius.pdf) (technical note 10/2020), [Secura/Quintix/Practum user manual](https://api.sartorius.com/document-hub/dam/download/21625/Manual_Secura_Quintix_Practum_WSE6004-e181008.pdf) ยง10.3 (WSE6004), [Cubis MSE operating instructions](https://api.sartorius.com/document-hub/dam/download/20494/Manual_Cubis_MSE_WMS6004-e.pdf) (WMS6004), [Cubis MCA operating instructions](https://www.sartorius.com/download/920578/manual-cubis-mca-micro-balances-wmc6028-e-pdf-data.pdf) (WMC6028), [SBI interface description](https://api.sartorius.com/document-hub/dam/download/22650/MAN-CC_Interface-e.pdf) (98647-000-53) |
|
|
25
|
+
| **Status** | ๐งช **simulated**: tested against a wire-level SBI simulator, not yet verified on hardware. [Report a hardware test](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml) |
|
|
26
|
+
|
|
27
|
+
## Try it without hardware
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uvx labmcp-sartorius --simulate --check
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Connect your balance
|
|
34
|
+
|
|
35
|
+
1. **Balance setup:** set the interface you use (RS232, USB-B / "PC-SBI", or Ethernet) to the **SBI** protocol, and set SBI data output to **manual, without stability** with **automatic output off** (factory settings on Cubis II and Entris II). With auto print on, `ESC P` toggles the automatic output instead of sending one reading. Either line format works (16 characters, or 22 with ID code).
|
|
36
|
+
2. **Serial settings:** factory settings are **9600 baud, 8 data bits, odd parity, 1 stop bit**, hardware handshake (Cubis II/MSE, Secura/Quintix/Practum, Entris II). The server uses these but does not enforce RTS/CTS (it keeps RTS asserted, which lets the balance transmit; USB virtual COM ports often never assert CTS). Add `?rtscts=true` to enforce it, or e.g. `?bytesize=7&parity=E` if your balance is set differently.
|
|
37
|
+
3. **Find the port:** `uvx labmcp ports`
|
|
38
|
+
4. **Test the connection:**
|
|
39
|
+
```bash
|
|
40
|
+
uvx labmcp-sartorius --address /dev/ttyUSB0 --check # Linux
|
|
41
|
+
uvx labmcp-sartorius --address /dev/tty.usbmodem14201 --check # macOS
|
|
42
|
+
uvx labmcp-sartorius --address COM4 --check # Windows
|
|
43
|
+
uvx labmcp-sartorius --address tcp://192.168.1.61:49155 --check # Cubis II via Ethernet (port as configured)
|
|
44
|
+
```
|
|
45
|
+
`--check` prints the model, serial number and software version (`ESC x1_`, `x2_`, `x3_`) when the balance supports them.
|
|
46
|
+
|
|
47
|
+
## Add to your MCP client
|
|
48
|
+
|
|
49
|
+
**Claude Code**
|
|
50
|
+
```bash
|
|
51
|
+
claude mcp add balance -- uvx labmcp-sartorius --address /dev/ttyUSB0
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Claude Desktop / Cursor / Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json`, โฆ)
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"balance": {
|
|
59
|
+
"command": "uvx",
|
|
60
|
+
"args": ["labmcp-sartorius", "--address", "/dev/ttyUSB0"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Add `--read-only` to allow weighing but block taring, zeroing and adjustment. For other clients: `uvx labmcp config sartorius-balance --address /dev/ttyUSB0 --client vscode`.
|
|
67
|
+
|
|
68
|
+
## Tools
|
|
69
|
+
|
|
70
|
+
<!-- TOOLS:START -->
|
|
71
|
+
| Tool | Kind | Description |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `get_command_log` | ๐ read | Return the most recent raw commands sent to / replies received from the instrument (newest last). Useful for debugging and for recording what was done. |
|
|
74
|
+
| `get_connection_info` | ๐ read | Report which instrument is connected (identity, address, simulated or real), whether the server is read-only, and the active safety limits. Call this first. |
|
|
75
|
+
| `lock_keypad` | ๐ control | Block or unblock the balance keys, e.g. so nobody tares by accident during a long logging run. |
|
|
76
|
+
| `log_weight_series` | ๐ read | Record a series of immediate (unfiltered) readings to monitor drift, evaporation, moisture uptake or stabilisation. Returns every reading plus summary statistics. |
|
|
77
|
+
| `read_weight` | ๐ read | Read the current net weight from the balance (ESC P). With stable=True the server polls until the balance reports a stable value; if it cannot settle (draughts, vibration, evaporation) you get an error; retry, or use stable=false for the current value. |
|
|
78
|
+
| `reconnect` | ๐ safety | Close and re-open the connection to the instrument (e.g. after it was power cycled or a cable was re-plugged). |
|
|
79
|
+
| `run_internal_adjustment` | ๐ control | Adjust (calibrate) the balance with its built-in weight (ESC Z; isoCAL models only). The pan must be empty and the balance undisturbed. SBI sends no completion message, so the server watches for the adjustment status and waits until the balance weighs again; check `observed_adjustment` and the balance display or GLP printout for the result. |
|
|
80
|
+
| `set_ambient_conditions` | ๐ control | Adapt the balance's filter to the ambient conditions (ESC K/L/M/N). Use 'unstable' or 'very_unstable' for draughty or vibrating benches (slower but steadier readings). |
|
|
81
|
+
| `tare` | ๐ control | Tare the balance (ESC U, or ESC T on legacy balances): the current load (e.g. an empty container) becomes the tare. Then waits for a stable reading and checks that it is ~0; returns that reading. |
|
|
82
|
+
| `zero` | ๐ control | Zero the balance (ESC V). Only works with the pan (nearly) empty, within the balance's zero-setting range; clears the tare. Confirms by reading ~0 afterwards. |
|
|
83
|
+
<!-- TOOLS:END -->
|
|
84
|
+
|
|
85
|
+
## Safety limits
|
|
86
|
+
|
|
87
|
+
| Limit | Default | Meaning |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `max_series_duration_s` | 600 s | Longest weight-logging series an agent may start |
|
|
90
|
+
|
|
91
|
+
Override at launch: `--limit max_series_duration_s=3600`. No tool of this server moves anything or heats, so there are no hazard tools.
|
|
92
|
+
|
|
93
|
+
## Example prompts
|
|
94
|
+
|
|
95
|
+
- "Tare the balance, then tell me the weight once it's stable after I add the sample."
|
|
96
|
+
- "Log the weight every 10 s for 5 minutes and tell me the evaporation rate in mg/min."
|
|
97
|
+
- "Weigh out ~250 mg: tell me how much more to add, reading every few seconds, until I'm within ยฑ2 mg."
|
|
98
|
+
- "The bench is vibrating today; switch the balance filter to unstable conditions and lock the keypad."
|
|
99
|
+
- "Run an internal adjustment before we start the calibration curve and tell me whether it ran."
|
|
100
|
+
|
|
101
|
+
## Notes
|
|
102
|
+
|
|
103
|
+
- **Stability comes from the unit symbol.** SBI has no explicit stability flag: the balance only sends the unit symbol (positions 12โ14) when the reading is stable ("If the weighing system has not stabilized, no unit symbol is output", interface description 98647-000-53; the newer manuals list those positions as "unit symbol or space"). `read_weight(stable=true)` therefore polls `ESC P` until a reading with a unit arrives. Unstable readings report the last unit seen.
|
|
104
|
+
- **Line formats.** The parser follows the documented fixed-width layouts: 16 characters (sign, value in positions 2โ10, unit in 12โ14, CR LF) and 22 characters (6-character ID code such as `N`, `G#`, `T`, `Stat`, then the same 16). It also accepts the Cubis II "one line with full length" format, a decimal comma, and a G#/T/N weighing block (the `N` line is reported). `High`, `Low`, `Err ###`, `APP.ERR`, `DIS.ERR` and `PRT.ERR` are reported as clear errors.
|
|
105
|
+
- **No acknowledgements.** SBI control commands (`ESC U` tare, `ESC V` zero, `ESC Z` adjust, `ESC K`โ`N` filter, `ESC O`/`R` keys) produce no reply, so a balance that does not support one silently ignores it. `tare` and `zero` therefore wait for a stable reading and check it is ~0, and report an error otherwise.
|
|
106
|
+
- **Zero vs tare.** `ESC V` only zeroes within the balance's zero-setting range (nearly empty pan); `ESC U` tares any load. Older balances (CP/CPA, LE, ...) only have `ESC T` (the tare key): start the server with `--option legacy=true`.
|
|
107
|
+
- **Internal adjustment (unverified).** `ESC Z` starts isoCAL on balances with a built-in weight. SBI sends no completion or result message; the server watches for the `Cal.` status (or a balance too busy to answer) and waits for a stable weight again, and reports `observed_adjustment=false` if it never sees one. Confirm the result on the display or GLP printout. The exact status output during adjustment could not be verified in the manuals.
|
|
108
|
+
- **Identity replies (unverified format).** `ESC x1_`/`x2_`/`x3_` print the model, serial number and software version; their exact layout is not documented, so the text is passed through as sent.
|
|
109
|
+
- The command log shows SBI traffic as hex (`1b 50 0d 0a` = `ESC P CR LF`) because of the ESC character.
|
|
110
|
+
- Not implemented: draft-shield and ionizer commands (Cubis MSE only), external adjustment (`ESC W`, needs a reference weight), `ESC S` restart and the function-key commands.
|
|
111
|
+
|
|
112
|
+
## Hardware verification
|
|
113
|
+
|
|
114
|
+
| Model | Firmware | Interface | Verified by | Date |
|
|
115
|
+
|---|---|---|---|---|
|
|
116
|
+
| *none yet: [be the first](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml)* | | | | |
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Sartorius Balance โ MCP Server
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.K-Dense-AI/labmcp-sartorius -->
|
|
4
|
+
|
|
5
|
+
Let an AI agent weigh samples, tare containers, log drift and evaporation, and run internal adjustments on **Sartorius laboratory balances** through the documented **SBI** (Sartorius Balance Interface) ASCII protocol.
|
|
6
|
+
|
|
7
|
+
| | |
|
|
8
|
+
|---|---|
|
|
9
|
+
| **Package** | `labmcp-sartorius` |
|
|
10
|
+
| **Instruments** | Cubis MSE, Cubis II MCA, Secura, Quintix, Practum, Entris II; older CP/CPA-series balances with `--option legacy=true`. Other SBI balances are likely to work |
|
|
11
|
+
| **Interfaces** | RS-232, USB (virtual COM port, "PC-SBI"), Ethernet (Cubis II "serial transmission via Ethernet", or any serial-to-Ethernet adapter) |
|
|
12
|
+
| **Protocol** | SBI: [Entris II interface description](https://www.sartorius.hr/media/dypfvdsn/entris-ii-technical-note-en-sartorius.pdf) (technical note 10/2020), [Secura/Quintix/Practum user manual](https://api.sartorius.com/document-hub/dam/download/21625/Manual_Secura_Quintix_Practum_WSE6004-e181008.pdf) ยง10.3 (WSE6004), [Cubis MSE operating instructions](https://api.sartorius.com/document-hub/dam/download/20494/Manual_Cubis_MSE_WMS6004-e.pdf) (WMS6004), [Cubis MCA operating instructions](https://www.sartorius.com/download/920578/manual-cubis-mca-micro-balances-wmc6028-e-pdf-data.pdf) (WMC6028), [SBI interface description](https://api.sartorius.com/document-hub/dam/download/22650/MAN-CC_Interface-e.pdf) (98647-000-53) |
|
|
13
|
+
| **Status** | ๐งช **simulated**: tested against a wire-level SBI simulator, not yet verified on hardware. [Report a hardware test](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml) |
|
|
14
|
+
|
|
15
|
+
## Try it without hardware
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uvx labmcp-sartorius --simulate --check
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Connect your balance
|
|
22
|
+
|
|
23
|
+
1. **Balance setup:** set the interface you use (RS232, USB-B / "PC-SBI", or Ethernet) to the **SBI** protocol, and set SBI data output to **manual, without stability** with **automatic output off** (factory settings on Cubis II and Entris II). With auto print on, `ESC P` toggles the automatic output instead of sending one reading. Either line format works (16 characters, or 22 with ID code).
|
|
24
|
+
2. **Serial settings:** factory settings are **9600 baud, 8 data bits, odd parity, 1 stop bit**, hardware handshake (Cubis II/MSE, Secura/Quintix/Practum, Entris II). The server uses these but does not enforce RTS/CTS (it keeps RTS asserted, which lets the balance transmit; USB virtual COM ports often never assert CTS). Add `?rtscts=true` to enforce it, or e.g. `?bytesize=7&parity=E` if your balance is set differently.
|
|
25
|
+
3. **Find the port:** `uvx labmcp ports`
|
|
26
|
+
4. **Test the connection:**
|
|
27
|
+
```bash
|
|
28
|
+
uvx labmcp-sartorius --address /dev/ttyUSB0 --check # Linux
|
|
29
|
+
uvx labmcp-sartorius --address /dev/tty.usbmodem14201 --check # macOS
|
|
30
|
+
uvx labmcp-sartorius --address COM4 --check # Windows
|
|
31
|
+
uvx labmcp-sartorius --address tcp://192.168.1.61:49155 --check # Cubis II via Ethernet (port as configured)
|
|
32
|
+
```
|
|
33
|
+
`--check` prints the model, serial number and software version (`ESC x1_`, `x2_`, `x3_`) when the balance supports them.
|
|
34
|
+
|
|
35
|
+
## Add to your MCP client
|
|
36
|
+
|
|
37
|
+
**Claude Code**
|
|
38
|
+
```bash
|
|
39
|
+
claude mcp add balance -- uvx labmcp-sartorius --address /dev/ttyUSB0
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Claude Desktop / Cursor / Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json`, โฆ)
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"balance": {
|
|
47
|
+
"command": "uvx",
|
|
48
|
+
"args": ["labmcp-sartorius", "--address", "/dev/ttyUSB0"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Add `--read-only` to allow weighing but block taring, zeroing and adjustment. For other clients: `uvx labmcp config sartorius-balance --address /dev/ttyUSB0 --client vscode`.
|
|
55
|
+
|
|
56
|
+
## Tools
|
|
57
|
+
|
|
58
|
+
<!-- TOOLS:START -->
|
|
59
|
+
| Tool | Kind | Description |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `get_command_log` | ๐ read | Return the most recent raw commands sent to / replies received from the instrument (newest last). Useful for debugging and for recording what was done. |
|
|
62
|
+
| `get_connection_info` | ๐ read | Report which instrument is connected (identity, address, simulated or real), whether the server is read-only, and the active safety limits. Call this first. |
|
|
63
|
+
| `lock_keypad` | ๐ control | Block or unblock the balance keys, e.g. so nobody tares by accident during a long logging run. |
|
|
64
|
+
| `log_weight_series` | ๐ read | Record a series of immediate (unfiltered) readings to monitor drift, evaporation, moisture uptake or stabilisation. Returns every reading plus summary statistics. |
|
|
65
|
+
| `read_weight` | ๐ read | Read the current net weight from the balance (ESC P). With stable=True the server polls until the balance reports a stable value; if it cannot settle (draughts, vibration, evaporation) you get an error; retry, or use stable=false for the current value. |
|
|
66
|
+
| `reconnect` | ๐ safety | Close and re-open the connection to the instrument (e.g. after it was power cycled or a cable was re-plugged). |
|
|
67
|
+
| `run_internal_adjustment` | ๐ control | Adjust (calibrate) the balance with its built-in weight (ESC Z; isoCAL models only). The pan must be empty and the balance undisturbed. SBI sends no completion message, so the server watches for the adjustment status and waits until the balance weighs again; check `observed_adjustment` and the balance display or GLP printout for the result. |
|
|
68
|
+
| `set_ambient_conditions` | ๐ control | Adapt the balance's filter to the ambient conditions (ESC K/L/M/N). Use 'unstable' or 'very_unstable' for draughty or vibrating benches (slower but steadier readings). |
|
|
69
|
+
| `tare` | ๐ control | Tare the balance (ESC U, or ESC T on legacy balances): the current load (e.g. an empty container) becomes the tare. Then waits for a stable reading and checks that it is ~0; returns that reading. |
|
|
70
|
+
| `zero` | ๐ control | Zero the balance (ESC V). Only works with the pan (nearly) empty, within the balance's zero-setting range; clears the tare. Confirms by reading ~0 afterwards. |
|
|
71
|
+
<!-- TOOLS:END -->
|
|
72
|
+
|
|
73
|
+
## Safety limits
|
|
74
|
+
|
|
75
|
+
| Limit | Default | Meaning |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `max_series_duration_s` | 600 s | Longest weight-logging series an agent may start |
|
|
78
|
+
|
|
79
|
+
Override at launch: `--limit max_series_duration_s=3600`. No tool of this server moves anything or heats, so there are no hazard tools.
|
|
80
|
+
|
|
81
|
+
## Example prompts
|
|
82
|
+
|
|
83
|
+
- "Tare the balance, then tell me the weight once it's stable after I add the sample."
|
|
84
|
+
- "Log the weight every 10 s for 5 minutes and tell me the evaporation rate in mg/min."
|
|
85
|
+
- "Weigh out ~250 mg: tell me how much more to add, reading every few seconds, until I'm within ยฑ2 mg."
|
|
86
|
+
- "The bench is vibrating today; switch the balance filter to unstable conditions and lock the keypad."
|
|
87
|
+
- "Run an internal adjustment before we start the calibration curve and tell me whether it ran."
|
|
88
|
+
|
|
89
|
+
## Notes
|
|
90
|
+
|
|
91
|
+
- **Stability comes from the unit symbol.** SBI has no explicit stability flag: the balance only sends the unit symbol (positions 12โ14) when the reading is stable ("If the weighing system has not stabilized, no unit symbol is output", interface description 98647-000-53; the newer manuals list those positions as "unit symbol or space"). `read_weight(stable=true)` therefore polls `ESC P` until a reading with a unit arrives. Unstable readings report the last unit seen.
|
|
92
|
+
- **Line formats.** The parser follows the documented fixed-width layouts: 16 characters (sign, value in positions 2โ10, unit in 12โ14, CR LF) and 22 characters (6-character ID code such as `N`, `G#`, `T`, `Stat`, then the same 16). It also accepts the Cubis II "one line with full length" format, a decimal comma, and a G#/T/N weighing block (the `N` line is reported). `High`, `Low`, `Err ###`, `APP.ERR`, `DIS.ERR` and `PRT.ERR` are reported as clear errors.
|
|
93
|
+
- **No acknowledgements.** SBI control commands (`ESC U` tare, `ESC V` zero, `ESC Z` adjust, `ESC K`โ`N` filter, `ESC O`/`R` keys) produce no reply, so a balance that does not support one silently ignores it. `tare` and `zero` therefore wait for a stable reading and check it is ~0, and report an error otherwise.
|
|
94
|
+
- **Zero vs tare.** `ESC V` only zeroes within the balance's zero-setting range (nearly empty pan); `ESC U` tares any load. Older balances (CP/CPA, LE, ...) only have `ESC T` (the tare key): start the server with `--option legacy=true`.
|
|
95
|
+
- **Internal adjustment (unverified).** `ESC Z` starts isoCAL on balances with a built-in weight. SBI sends no completion or result message; the server watches for the `Cal.` status (or a balance too busy to answer) and waits for a stable weight again, and reports `observed_adjustment=false` if it never sees one. Confirm the result on the display or GLP printout. The exact status output during adjustment could not be verified in the manuals.
|
|
96
|
+
- **Identity replies (unverified format).** `ESC x1_`/`x2_`/`x3_` print the model, serial number and software version; their exact layout is not documented, so the text is passed through as sent.
|
|
97
|
+
- The command log shows SBI traffic as hex (`1b 50 0d 0a` = `ESC P CR LF`) because of the ESC character.
|
|
98
|
+
- Not implemented: draft-shield and ionizer commands (Cubis MSE only), external adjustment (`ESC W`, needs a reference weight), `ESC S` restart and the function-key commands.
|
|
99
|
+
|
|
100
|
+
## Hardware verification
|
|
101
|
+
|
|
102
|
+
| Model | Firmware | Interface | Verified by | Date |
|
|
103
|
+
|---|---|---|---|---|
|
|
104
|
+
| *none yet: [be the first](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml)* | | | | |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "labmcp-sartorius"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for Sartorius laboratory balances (SBI protocol): weigh, tare, zero, log drift, internal adjustment."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [{ name = "K-Dense and LabMCP contributors" }]
|
|
9
|
+
keywords = ["mcp", "lab-instrument", "balance", "sartorius", "sbi", "weighing"]
|
|
10
|
+
dependencies = ["labmcp>=0.1,<0.2"]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
labmcp-sartorius = "labmcp_sartorius.server:main"
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/sartorius-balance"
|
|
17
|
+
|
|
18
|
+
[tool.labmcp]
|
|
19
|
+
name = "Sartorius Balance"
|
|
20
|
+
domain = "chemistry"
|
|
21
|
+
category = "Weighing"
|
|
22
|
+
vendor = "Sartorius"
|
|
23
|
+
models = ["Cubis MSE", "Cubis II MCA", "Secura", "Quintix", "Practum", "Entris II", "CP/CPA (legacy)"]
|
|
24
|
+
interfaces = ["RS-232", "USB (virtual COM)", "Ethernet (Cubis II)"]
|
|
25
|
+
protocol = "SBI (Sartorius Balance Interface)"
|
|
26
|
+
summary = "Weigh (stable/immediate), tare, zero, log drift series, internal adjustment, ambient filter, keypad lock."
|
|
27
|
+
status = "simulated"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/labmcp_sartorius"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.K-Dense-AI/labmcp-sartorius",
|
|
4
|
+
"title": "Sartorius Balance",
|
|
5
|
+
"description": "MCP server for Sartorius laboratory balances (SBI protocol): weigh, tare, zero, log drift, internal ",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"url": "https://github.com/K-Dense-AI/lab-instrument-mcps",
|
|
9
|
+
"source": "github",
|
|
10
|
+
"subfolder": "servers/chemistry/sartorius-balance"
|
|
11
|
+
},
|
|
12
|
+
"websiteUrl": "https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/sartorius-balance",
|
|
13
|
+
"packages": [
|
|
14
|
+
{
|
|
15
|
+
"registryType": "pypi",
|
|
16
|
+
"registryBaseUrl": "https://pypi.org",
|
|
17
|
+
"identifier": "labmcp-sartorius",
|
|
18
|
+
"version": "0.1.0",
|
|
19
|
+
"transport": {
|
|
20
|
+
"type": "stdio"
|
|
21
|
+
},
|
|
22
|
+
"environmentVariables": [
|
|
23
|
+
{
|
|
24
|
+
"name": "LABMCP_ADDRESS",
|
|
25
|
+
"description": "Instrument address, e.g. serial:///dev/ttyUSB0 or tcp://192.168.1.50:5025",
|
|
26
|
+
"isRequired": false
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "LABMCP_SIMULATE",
|
|
30
|
+
"description": "Set to 1 to use the built-in simulator (no hardware)",
|
|
31
|
+
"isRequired": false
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "LABMCP_READ_ONLY",
|
|
35
|
+
"description": "Set to 1 to disable all state-changing tools",
|
|
36
|
+
"isRequired": false
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "LABMCP_LIMITS",
|
|
40
|
+
"description": "Safety limit overrides, e.g. max_temperature_c=80",
|
|
41
|
+
"isRequired": false
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LabMCP server for Sartorius laboratory balances (SBI protocol)."""
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""SBI (Sartorius Balance Interface) driver for Sartorius laboratory balances.
|
|
2
|
+
|
|
3
|
+
Commands, data formats and interface settings were verified in:
|
|
4
|
+
|
|
5
|
+
* "Entris II - Description of the Interface", Sartorius Technical Note (10/2020),
|
|
6
|
+
https://www.sartorius.hr/media/dypfvdsn/entris-ii-technical-note-en-sartorius.pdf
|
|
7
|
+
* "User Manual Secura, Quintix, Practum" (WSE6004-e181008), chapter 10.3 "Interface Specification",
|
|
8
|
+
https://api.sartorius.com/document-hub/dam/download/21625/Manual_Secura_Quintix_Practum_WSE6004-e181008.pdf
|
|
9
|
+
* "Operating Instructions Cubis Series MSE" (WMS6004-e190715), "Data Interfaces",
|
|
10
|
+
https://api.sartorius.com/document-hub/dam/download/20494/Manual_Cubis_MSE_WMS6004-e.pdf
|
|
11
|
+
* "Operating Instructions Cubis MCA" (WMC6028-e211007), "Connections / SBI Protocol" menu,
|
|
12
|
+
https://www.sartorius.com/download/920578/manual-cubis-mca-micro-balances-wmc6028-e-pdf-data.pdf
|
|
13
|
+
* "Sartorius Comparator Interface Description for the CC Model Series" (98647-000-53),
|
|
14
|
+
https://api.sartorius.com/document-hub/dam/download/22650/MAN-CC_Interface-e.pdf
|
|
15
|
+
(source of the rule "If the weighing system has not stabilized, no unit symbol is output")
|
|
16
|
+
|
|
17
|
+
Commands are ``ESC <char> [CR LF]`` (format 1, e.g. ``ESC P``) or ``ESC <char><digit>_ [CR LF]``
|
|
18
|
+
(format 2, e.g. ``ESC x1_``). Only ``ESC P`` (print) and the ``ESC x#_`` info commands produce
|
|
19
|
+
output; the others (tare, zero, adjustment, filter, key lock) are silent.
|
|
20
|
+
|
|
21
|
+
A weight line is 16 characters (14 + CR LF)::
|
|
22
|
+
|
|
23
|
+
pos 1 sign (+, - or space)
|
|
24
|
+
pos 2-10 value, right-aligned, leading zeros as spaces
|
|
25
|
+
pos 11 space
|
|
26
|
+
pos 12-14 unit symbol, or spaces while the reading is not stable
|
|
27
|
+
pos 15-16 CR LF
|
|
28
|
+
|
|
29
|
+
or 22 characters, where a 6-character ID code (``N``, ``G#``, ``T``, ``Stat``...) precedes
|
|
30
|
+
the same 16 characters. Special lines report ``High`` (overload), ``Low`` (underload),
|
|
31
|
+
``Cal.Ext.``/``Cal.Int.`` (adjustment) and errors ``Err ###``, ``APP.ERR``, ``DIS.ERR``,
|
|
32
|
+
``PRT.ERR``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
import time
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
|
|
41
|
+
from labmcp import InstrumentProtocolError, InstrumentTimeout, Transport
|
|
42
|
+
|
|
43
|
+
ESC = "\x1b"
|
|
44
|
+
|
|
45
|
+
AMBIENT_FILTERS = {"very_stable": "K", "stable": "L", "unstable": "M", "very_unstable": "N"}
|
|
46
|
+
|
|
47
|
+
_VALUE_RE = re.compile(r"^[+-]?(?:\d+(?:[.,]\d*)?|[.,]\d+)$")
|
|
48
|
+
# Position-independent fallback, e.g. for "output in one line with full length" (Cubis II).
|
|
49
|
+
_LOOSE_RE = re.compile(
|
|
50
|
+
r"^(?P<id>.*?)\s*(?P<sign>[+-])?\s*(?P<value>\d+(?:[.,]\d*)?|[.,]\d+)\s*(?P<unit>[^\s\d.,+\-]\S{0,3})?\s*$"
|
|
51
|
+
)
|
|
52
|
+
_ERR_RE = re.compile(r"\bErr\s*(\d+)", re.IGNORECASE)
|
|
53
|
+
_APP_ERR_RE = re.compile(r"\b(APP|DIS|PRT)\.ERR", re.IGNORECASE)
|
|
54
|
+
|
|
55
|
+
_SPECIAL = {
|
|
56
|
+
"High": "the balance is overloaded (High): remove weight from the pan",
|
|
57
|
+
"Low": "the balance is in underload (Low): is the weighing pan in place and free?",
|
|
58
|
+
}
|
|
59
|
+
_APP_ERR = {"APP": "application error", "DIS": "display error", "PRT": "printer/output error"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class SBIReading:
|
|
64
|
+
value: float
|
|
65
|
+
unit: str | None # None when the balance sent no unit (it omits it while unstable)
|
|
66
|
+
stable: bool
|
|
67
|
+
ident: str # 6-character ID code of the 22-character format ("" for 16 characters)
|
|
68
|
+
decimals: int
|
|
69
|
+
raw: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BalanceBusy(InstrumentProtocolError):
|
|
73
|
+
"""The balance sent an adjustment status line (``Cal.Int.`` / ``Cal.Ext.``) instead of a weight."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_sbi_line(line: str) -> SBIReading:
|
|
77
|
+
"""Parse one SBI data line (without CR LF) into a reading, or raise a helpful error."""
|
|
78
|
+
raw = line.rstrip("\r\n")
|
|
79
|
+
if not raw.strip():
|
|
80
|
+
raise InstrumentProtocolError("The balance sent an empty line.")
|
|
81
|
+
for word, meaning in _SPECIAL.items():
|
|
82
|
+
if re.search(rf"\b{word}\b", raw):
|
|
83
|
+
raise InstrumentProtocolError(f"Balance sent {raw.strip()!r}: {meaning}.")
|
|
84
|
+
if m := _ERR_RE.search(raw):
|
|
85
|
+
raise InstrumentProtocolError(
|
|
86
|
+
f"Balance sent {raw.strip()!r}: error {m.group(1)}; see the troubleshooting table in the "
|
|
87
|
+
"balance's operating instructions."
|
|
88
|
+
)
|
|
89
|
+
if m := _APP_ERR_RE.search(raw):
|
|
90
|
+
raise InstrumentProtocolError(f"Balance sent {raw.strip()!r}: {_APP_ERR[m.group(1).upper()]}.")
|
|
91
|
+
if "Cal." in raw:
|
|
92
|
+
raise BalanceBusy(f"Balance sent {raw.strip()!r}: an adjustment/calibration is in progress.")
|
|
93
|
+
|
|
94
|
+
if len(raw) in (14, 20): # the documented fixed-width 16- and 22-character formats
|
|
95
|
+
ident, body = (raw[:6].strip(), raw[6:]) if len(raw) == 20 else ("", raw)
|
|
96
|
+
sign, number, gap, unit = body[0], body[1:10].replace(" ", ""), body[10], body[11:14].strip()
|
|
97
|
+
if sign in "+- " and gap == " " and _VALUE_RE.match(number):
|
|
98
|
+
return _reading(sign, number, unit, ident, raw)
|
|
99
|
+
m = _LOOSE_RE.match(raw)
|
|
100
|
+
if not m or m["id"].strip().startswith("Stat"):
|
|
101
|
+
raise InstrumentProtocolError(f"Unexpected SBI data from the balance: {raw!r}")
|
|
102
|
+
return _reading(m["sign"] or "+", m["value"], (m["unit"] or "").strip(), m["id"].strip(), raw)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _reading(sign: str, number: str, unit: str, ident: str, raw: str) -> SBIReading:
|
|
106
|
+
number = number.replace(",", ".")
|
|
107
|
+
value = float(number)
|
|
108
|
+
if sign == "-" and not number.startswith("-"):
|
|
109
|
+
value = -value
|
|
110
|
+
decimals = len(number.split(".")[1]) if "." in number else 0
|
|
111
|
+
return SBIReading(value, unit or None, bool(unit), ident, decimals, raw)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SBIBalance:
|
|
115
|
+
def __init__(self, transport: Transport, legacy: bool = False) -> None:
|
|
116
|
+
self.t = transport
|
|
117
|
+
#: Older balances (CP/CPA, LE...) only know ESC T for taring and have no ESC U / ESC V.
|
|
118
|
+
self.legacy = legacy
|
|
119
|
+
#: Last unit symbol seen (the balance omits it in unstable readings).
|
|
120
|
+
self.last_unit: str | None = None
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------ low level
|
|
123
|
+
|
|
124
|
+
def send(self, command: str) -> None:
|
|
125
|
+
"""Send ``ESC <command> CR LF``. SBI control commands have no reply."""
|
|
126
|
+
self.t.write(ESC + command)
|
|
127
|
+
|
|
128
|
+
def _lines(self, command: str, timeout: float | None = None) -> list[str]:
|
|
129
|
+
"""Send a command and collect its output: one line, plus up to 5 more that follow within
|
|
130
|
+
0.15 s of each other (e.g. the date/time line or a G#/T/N weighing block)."""
|
|
131
|
+
with self.t.lock:
|
|
132
|
+
self.t.flush_input()
|
|
133
|
+
self.send(command)
|
|
134
|
+
try:
|
|
135
|
+
lines = [self.t.read(timeout)]
|
|
136
|
+
except InstrumentTimeout as exc:
|
|
137
|
+
raise InstrumentTimeout(
|
|
138
|
+
f"No reply to ESC {command}. Check that the balance's interface is set to SBI with "
|
|
139
|
+
"matching baud rate/parity, and that data output is 'manual without stability' "
|
|
140
|
+
f"(with 'after stability' the balance waits until the reading settles). ({exc})"
|
|
141
|
+
) from exc
|
|
142
|
+
for _ in range(5): # bounded, in case the balance is streaming (auto print on)
|
|
143
|
+
try:
|
|
144
|
+
lines.append(self.t.read(0.15))
|
|
145
|
+
except InstrumentTimeout:
|
|
146
|
+
break
|
|
147
|
+
return lines
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------ identity
|
|
150
|
+
|
|
151
|
+
def info(self, number: int) -> str | None:
|
|
152
|
+
"""``ESC x#_``: 1 = model, 2 = serial number, 3 = balance software version."""
|
|
153
|
+
try:
|
|
154
|
+
lines = self._lines(f"x{number}_", timeout=1.5)
|
|
155
|
+
except InstrumentTimeout:
|
|
156
|
+
return None
|
|
157
|
+
return " ".join(line.strip() for line in lines if line.strip()) or None
|
|
158
|
+
|
|
159
|
+
def identify(self) -> dict[str, str]:
|
|
160
|
+
info = {"manufacturer": "Sartorius", "protocol": "SBI"}
|
|
161
|
+
for key, number in (("model", 1), ("serial", 2), ("software", 3)):
|
|
162
|
+
value = self.info(number)
|
|
163
|
+
if value:
|
|
164
|
+
info[key] = value
|
|
165
|
+
return info
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------ weighing
|
|
168
|
+
|
|
169
|
+
def print_reading(self, timeout: float | None = None) -> SBIReading:
|
|
170
|
+
"""``ESC P``: one reading, stable or not (as configured, 'without stability')."""
|
|
171
|
+
lines = [line for line in self._lines("P", timeout) if line.strip()]
|
|
172
|
+
if not lines:
|
|
173
|
+
raise InstrumentProtocolError("The balance answered ESC P with an empty line.")
|
|
174
|
+
readings = [parse_sbi_line(line) for line in lines if not _is_info_line(line)]
|
|
175
|
+
if not readings:
|
|
176
|
+
raise InstrumentProtocolError(f"No weight value in the balance's output: {lines!r}")
|
|
177
|
+
# A G#/T/N weighing block: report the net value.
|
|
178
|
+
reading = next((r for r in readings if r.ident == "N"), readings[-1])
|
|
179
|
+
if reading.unit:
|
|
180
|
+
self.last_unit = reading.unit
|
|
181
|
+
return reading
|
|
182
|
+
|
|
183
|
+
def weight(self, stable: bool = True, timeout: float = 20.0) -> SBIReading:
|
|
184
|
+
"""Read the weight. With ``stable=True``, poll ``ESC P`` until the balance reports a
|
|
185
|
+
stable value (unit symbol present) or ``timeout`` expires."""
|
|
186
|
+
if not stable:
|
|
187
|
+
return self.print_reading()
|
|
188
|
+
deadline = time.monotonic() + timeout
|
|
189
|
+
last: SBIReading | None = None
|
|
190
|
+
while True:
|
|
191
|
+
remaining = deadline - time.monotonic()
|
|
192
|
+
if remaining <= 0:
|
|
193
|
+
break
|
|
194
|
+
try:
|
|
195
|
+
last = self.print_reading(timeout=max(0.5, remaining))
|
|
196
|
+
except BalanceBusy:
|
|
197
|
+
last = None
|
|
198
|
+
else:
|
|
199
|
+
if last.stable:
|
|
200
|
+
return last
|
|
201
|
+
time.sleep(min(0.2, max(0.0, deadline - time.monotonic())))
|
|
202
|
+
where = f" (last reading {last.value:g}, unstable)" if last else ""
|
|
203
|
+
raise InstrumentProtocolError(
|
|
204
|
+
f"The balance did not report a stable reading within {timeout:g} s{where}. Check for "
|
|
205
|
+
"draughts, vibration or a sample that is evaporating, or read with stable=false."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def tare(self) -> None:
|
|
209
|
+
"""``ESC U`` (tare key); ``ESC T`` on legacy balances."""
|
|
210
|
+
self.send("T" if self.legacy else "U")
|
|
211
|
+
|
|
212
|
+
def zero(self) -> None:
|
|
213
|
+
"""``ESC V`` (zero key). Not available on legacy balances."""
|
|
214
|
+
if self.legacy:
|
|
215
|
+
raise InstrumentProtocolError(
|
|
216
|
+
"Legacy SBI balances have no separate zero command; use tare (ESC T), which zeroes "
|
|
217
|
+
"when the pan is empty."
|
|
218
|
+
)
|
|
219
|
+
self.send("V")
|
|
220
|
+
|
|
221
|
+
def start_internal_adjustment(self) -> None:
|
|
222
|
+
"""``ESC Z``: internal calibration/adjustment (balances with a built-in weight only)."""
|
|
223
|
+
self.send("Z")
|
|
224
|
+
|
|
225
|
+
def set_ambient_filter(self, level: str) -> None:
|
|
226
|
+
"""``ESC K/L/M/N``: adapt the filter to very stable ... very unstable conditions."""
|
|
227
|
+
self.send(AMBIENT_FILTERS[level])
|
|
228
|
+
|
|
229
|
+
def lock_keys(self, locked: bool) -> None:
|
|
230
|
+
"""``ESC O`` blocks the keypad, ``ESC R`` unblocks it."""
|
|
231
|
+
self.send("O" if locked else "R")
|
|
232
|
+
|
|
233
|
+
def close(self) -> None:
|
|
234
|
+
self.t.close()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _is_info_line(line: str) -> bool:
|
|
238
|
+
"""Date/time lines of the 'date & time, value' output format."""
|
|
239
|
+
return bool(re.search(r"\d{1,2}[./-]\d{1,2}[./-]\d{2,4}|\d{1,2}:\d{2}", line)) and not re.search(
|
|
240
|
+
r"\s(?:g|mg|kg|ct|lb|oz|ozt)\s*$", line
|
|
241
|
+
)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""MCP server for Sartorius laboratory balances (SBI protocol)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import statistics
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Annotated, Literal
|
|
9
|
+
|
|
10
|
+
from labmcp import (
|
|
11
|
+
CONTROL,
|
|
12
|
+
READ,
|
|
13
|
+
ConnectContext,
|
|
14
|
+
InstrumentProtocolError,
|
|
15
|
+
InstrumentServer,
|
|
16
|
+
InstrumentTimeout,
|
|
17
|
+
Limit,
|
|
18
|
+
)
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
from labmcp_sartorius.driver import BalanceBusy, SBIBalance, SBIReading
|
|
22
|
+
from labmcp_sartorius.simulator import SBISimulator
|
|
23
|
+
|
|
24
|
+
#: Seconds between status polls while an internal adjustment runs.
|
|
25
|
+
ADJUST_POLL_S = 1.0
|
|
26
|
+
#: If no adjustment status is seen within this time, assume the balance did not start one.
|
|
27
|
+
ADJUST_NO_SIGN_S = 15.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def connect(ctx: ConnectContext) -> SBIBalance:
|
|
31
|
+
# Factory settings of Cubis II/MSE, Secura/Quintix/Practum and Entris II: 9600 baud,
|
|
32
|
+
# 8 data bits, odd parity, 1 stop bit; commands and replies end with CR LF.
|
|
33
|
+
# The balances default to hardware handshake on RS-232; rtscts stays off by default
|
|
34
|
+
# because pyserial keeps RTS asserted (all the balance needs to transmit) and USB
|
|
35
|
+
# virtual COM ports may never raise CTS.
|
|
36
|
+
transport = ctx.open_transport(
|
|
37
|
+
simulator=SBISimulator,
|
|
38
|
+
baudrate=9600,
|
|
39
|
+
bytesize=8,
|
|
40
|
+
parity="O",
|
|
41
|
+
stopbits=1,
|
|
42
|
+
rtscts=False,
|
|
43
|
+
read_termination="\r\n",
|
|
44
|
+
write_termination="\r\n",
|
|
45
|
+
timeout=3.0,
|
|
46
|
+
)
|
|
47
|
+
legacy = (ctx.option("legacy", "false") or "false").lower() in {"1", "true", "yes", "on"}
|
|
48
|
+
return SBIBalance(transport, legacy=legacy)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
server = InstrumentServer(
|
|
52
|
+
"Sartorius Balance (SBI)",
|
|
53
|
+
connect=connect,
|
|
54
|
+
package="labmcp-sartorius",
|
|
55
|
+
instructions="""
|
|
56
|
+
Controls a Sartorius laboratory balance over SBI (Cubis MSE / Cubis II MCA, Secura, Quintix,
|
|
57
|
+
Practum, Entris II; older CP/CPA with --option legacy=true).
|
|
58
|
+
- `read_weight` with stable=True polls until the balance reports a stable value (SBI marks a
|
|
59
|
+
stable reading by sending its unit symbol); stable=False returns the current value at once.
|
|
60
|
+
- Weights are net weights (gross minus tare) in the unit shown on the balance.
|
|
61
|
+
- Tare before weighing into a container; `zero` only works when the load is within the balance's
|
|
62
|
+
zero-setting range (near empty pan).
|
|
63
|
+
- SBI commands are not acknowledged: `tare` and `zero` confirm by reading the balance afterwards.
|
|
64
|
+
- Internal adjustment only exists on balances with a built-in weight, and SBI does not report
|
|
65
|
+
success; check the returned message.
|
|
66
|
+
""",
|
|
67
|
+
limits=[
|
|
68
|
+
Limit("max_series_duration_s", 600, "s", "Longest allowed weight-logging series"),
|
|
69
|
+
],
|
|
70
|
+
address_help="""\
|
|
71
|
+
serial:///dev/ttyUSB0 RS-232 or USB virtual COM (defaults 9600 baud, 8 data bits, odd parity)
|
|
72
|
+
serial://COM4?parity=N&bytesize=8 Windows, balance set to no parity
|
|
73
|
+
tcp://192.168.1.61:49155 Cubis II "Serial transmission via Ethernet" (port as configured)""",
|
|
74
|
+
option_help={
|
|
75
|
+
"legacy": "true for older balances (CP/CPA, LE...) that only have ESC T for tare/zero",
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
mcp = server.mcp
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WeightReading(BaseModel):
|
|
82
|
+
value: float = Field(description="Net weight value")
|
|
83
|
+
unit: str | None = Field(
|
|
84
|
+
description="Unit sent by the balance (e.g. 'g'). SBI omits it for unstable readings; then this "
|
|
85
|
+
"is the last unit seen, or None"
|
|
86
|
+
)
|
|
87
|
+
stable: bool = Field(description="True if the balance marked the reading as stable")
|
|
88
|
+
id_code: str = Field(description="SBI 22-character-format ID code (e.g. 'N' = net); '' in 16-character format")
|
|
89
|
+
timestamp: str = Field(description="UTC time the reading was taken (ISO 8601)")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class WeightSeries(BaseModel):
|
|
93
|
+
readings: list[WeightReading]
|
|
94
|
+
unit: str | None
|
|
95
|
+
count: int
|
|
96
|
+
mean: float
|
|
97
|
+
stdev: float
|
|
98
|
+
minimum: float
|
|
99
|
+
maximum: float
|
|
100
|
+
drift_per_min: float = Field(description="Least-squares slope of weight vs time, in unit/min")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AdjustmentResult(BaseModel):
|
|
104
|
+
observed_adjustment: bool = Field(
|
|
105
|
+
description="True if the balance reported an adjustment in progress (Cal.* status or busy)"
|
|
106
|
+
)
|
|
107
|
+
duration_s: float
|
|
108
|
+
final_reading: WeightReading | None
|
|
109
|
+
message: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _now() -> str:
|
|
113
|
+
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _reading(r: SBIReading, last_unit: str | None) -> WeightReading:
|
|
117
|
+
return WeightReading(
|
|
118
|
+
value=r.value, unit=r.unit or last_unit, stable=r.stable, id_code=r.ident, timestamp=_now()
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _drv() -> SBIBalance:
|
|
123
|
+
return server.driver
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _settled_near_zero(what: str, timeout_s: float) -> WeightReading:
|
|
127
|
+
"""After an (unacknowledged) tare/zero command, wait for a stable reading of ~0."""
|
|
128
|
+
drv = _drv()
|
|
129
|
+
deadline = time.monotonic() + timeout_s
|
|
130
|
+
while True:
|
|
131
|
+
# The first stable reading may still be the one from before the command was processed.
|
|
132
|
+
r = drv.weight(stable=True, timeout=max(1.0, deadline - time.monotonic()))
|
|
133
|
+
if abs(r.value) <= 5 * 10 ** (-r.decimals): # within 5 digits of zero
|
|
134
|
+
return _reading(r, drv.last_unit)
|
|
135
|
+
if time.monotonic() >= deadline:
|
|
136
|
+
break
|
|
137
|
+
time.sleep(0.3)
|
|
138
|
+
hint = (
|
|
139
|
+
"Older balances (CP/CPA, LE, ...) only understand ESC T: start the server with --option legacy=true."
|
|
140
|
+
if not drv.legacy
|
|
141
|
+
else "The balance may not have accepted the command."
|
|
142
|
+
)
|
|
143
|
+
zero_hint = (
|
|
144
|
+
"For zero, the load must be within the zero-setting range (near an empty pan); use tare instead. "
|
|
145
|
+
if what == "zero"
|
|
146
|
+
else ""
|
|
147
|
+
)
|
|
148
|
+
raise InstrumentProtocolError(
|
|
149
|
+
f"Sent the {what} command but the balance still reads {r.value:g} {r.unit or ''} (stable). "
|
|
150
|
+
+ zero_hint
|
|
151
|
+
+ hint
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@mcp.tool(**READ, timeout=90)
|
|
156
|
+
def read_weight(
|
|
157
|
+
stable: Annotated[bool, Field(description="Wait for a stable reading (recommended)")] = True,
|
|
158
|
+
timeout_s: Annotated[float, Field(ge=1, le=60, description="Max. seconds to wait for stability")] = 20,
|
|
159
|
+
) -> WeightReading:
|
|
160
|
+
"""Read the current net weight from the balance (ESC P). With stable=True the server polls
|
|
161
|
+
until the balance reports a stable value; if it cannot settle (draughts, vibration,
|
|
162
|
+
evaporation) you get an error; retry, or use stable=false for the current value."""
|
|
163
|
+
drv = _drv()
|
|
164
|
+
r = drv.weight(stable=stable, timeout=timeout_s)
|
|
165
|
+
return _reading(r, drv.last_unit)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@mcp.tool(**READ, timeout=900)
|
|
169
|
+
def log_weight_series(
|
|
170
|
+
count: Annotated[int, Field(ge=2, le=1000, description="Number of readings")] = 10,
|
|
171
|
+
interval_s: Annotated[float, Field(ge=0.5, le=600, description="Seconds between readings")] = 1.0,
|
|
172
|
+
) -> WeightSeries:
|
|
173
|
+
"""Record a series of immediate (unfiltered) readings to monitor drift, evaporation,
|
|
174
|
+
moisture uptake or stabilisation. Returns every reading plus summary statistics."""
|
|
175
|
+
server.check("max_series_duration_s", (count - 1) * interval_s, "series duration")
|
|
176
|
+
drv = _drv()
|
|
177
|
+
readings: list[WeightReading] = []
|
|
178
|
+
times: list[float] = []
|
|
179
|
+
t0 = time.monotonic()
|
|
180
|
+
for i in range(count):
|
|
181
|
+
delay = t0 + i * interval_s - time.monotonic()
|
|
182
|
+
if delay > 0:
|
|
183
|
+
time.sleep(delay)
|
|
184
|
+
times.append(time.monotonic() - t0)
|
|
185
|
+
readings.append(_reading(drv.weight(stable=False), drv.last_unit))
|
|
186
|
+
values = [r.value for r in readings]
|
|
187
|
+
tbar, vbar = statistics.fmean(times), statistics.fmean(values)
|
|
188
|
+
denom = sum((t - tbar) ** 2 for t in times)
|
|
189
|
+
slope = sum((t - tbar) * (v - vbar) for t, v in zip(times, values, strict=True)) / denom if denom else 0.0
|
|
190
|
+
return WeightSeries(
|
|
191
|
+
readings=readings,
|
|
192
|
+
unit=drv.last_unit,
|
|
193
|
+
count=count,
|
|
194
|
+
mean=vbar,
|
|
195
|
+
stdev=statistics.stdev(values),
|
|
196
|
+
minimum=min(values),
|
|
197
|
+
maximum=max(values),
|
|
198
|
+
drift_per_min=slope * 60.0,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@mcp.tool(**CONTROL, timeout=90)
|
|
203
|
+
def tare(
|
|
204
|
+
timeout_s: Annotated[float, Field(ge=1, le=60, description="Max. seconds to wait for the tared reading")] = 10,
|
|
205
|
+
) -> WeightReading:
|
|
206
|
+
"""Tare the balance (ESC U, or ESC T on legacy balances): the current load (e.g. an empty
|
|
207
|
+
container) becomes the tare. Then waits for a stable reading and checks that it is ~0;
|
|
208
|
+
returns that reading."""
|
|
209
|
+
_drv().tare()
|
|
210
|
+
return _settled_near_zero("tare", timeout_s)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@mcp.tool(**CONTROL, timeout=90)
|
|
214
|
+
def zero(
|
|
215
|
+
timeout_s: Annotated[float, Field(ge=1, le=60, description="Max. seconds to wait for the zeroed reading")] = 10,
|
|
216
|
+
) -> WeightReading:
|
|
217
|
+
"""Zero the balance (ESC V). Only works with the pan (nearly) empty, within the balance's
|
|
218
|
+
zero-setting range; clears the tare. Confirms by reading ~0 afterwards."""
|
|
219
|
+
_drv().zero()
|
|
220
|
+
return _settled_near_zero("zero", timeout_s)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@mcp.tool(**CONTROL, timeout=330)
|
|
224
|
+
def run_internal_adjustment(
|
|
225
|
+
timeout_s: Annotated[float, Field(ge=30, le=300, description="Max. seconds for the adjustment")] = 240,
|
|
226
|
+
) -> AdjustmentResult:
|
|
227
|
+
"""Adjust (calibrate) the balance with its built-in weight (ESC Z; isoCAL models only).
|
|
228
|
+
The pan must be empty and the balance undisturbed. SBI sends no completion message, so the
|
|
229
|
+
server watches for the adjustment status and waits until the balance weighs again; check
|
|
230
|
+
`observed_adjustment` and the balance display or GLP printout for the result."""
|
|
231
|
+
drv = _drv()
|
|
232
|
+
drv.start_internal_adjustment()
|
|
233
|
+
t0 = time.monotonic()
|
|
234
|
+
busy_seen = False
|
|
235
|
+
final: SBIReading | None = None
|
|
236
|
+
while time.monotonic() - t0 < timeout_s:
|
|
237
|
+
time.sleep(ADJUST_POLL_S)
|
|
238
|
+
try:
|
|
239
|
+
r = drv.print_reading(timeout=2.0)
|
|
240
|
+
except (BalanceBusy, InstrumentTimeout): # "Cal.Int." status, or too busy to answer
|
|
241
|
+
busy_seen = True
|
|
242
|
+
continue
|
|
243
|
+
if busy_seen and r.stable:
|
|
244
|
+
final = r
|
|
245
|
+
break
|
|
246
|
+
if not busy_seen and time.monotonic() - t0 > ADJUST_NO_SIGN_S:
|
|
247
|
+
final = r
|
|
248
|
+
break
|
|
249
|
+
elapsed = time.monotonic() - t0
|
|
250
|
+
if busy_seen and final is not None:
|
|
251
|
+
message = f"The balance reported an adjustment and returned to weighing after {elapsed:.0f} s."
|
|
252
|
+
elif busy_seen:
|
|
253
|
+
message = f"The balance was still adjusting after {timeout_s:g} s; check its display."
|
|
254
|
+
else:
|
|
255
|
+
message = (
|
|
256
|
+
f"No sign of an adjustment was seen within {ADJUST_NO_SIGN_S:g} s: the balance may have no "
|
|
257
|
+
"internal weight, "
|
|
258
|
+
"adjustment may be disabled or locked in its menu (verified models), or it adjusts without "
|
|
259
|
+
"reporting a status over SBI. Check the display."
|
|
260
|
+
)
|
|
261
|
+
return AdjustmentResult(
|
|
262
|
+
observed_adjustment=busy_seen,
|
|
263
|
+
duration_s=round(elapsed, 1),
|
|
264
|
+
final_reading=_reading(final, drv.last_unit) if final else None,
|
|
265
|
+
message=message,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@mcp.tool(**CONTROL)
|
|
270
|
+
def set_ambient_conditions(
|
|
271
|
+
conditions: Literal["very_stable", "stable", "unstable", "very_unstable"],
|
|
272
|
+
) -> str:
|
|
273
|
+
"""Adapt the balance's filter to the ambient conditions (ESC K/L/M/N). Use 'unstable' or
|
|
274
|
+
'very_unstable' for draughty or vibrating benches (slower but steadier readings)."""
|
|
275
|
+
_drv().set_ambient_filter(conditions)
|
|
276
|
+
return f"Ambient filter set to '{conditions}'."
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@mcp.tool(**CONTROL)
|
|
280
|
+
def lock_keypad(
|
|
281
|
+
locked: Annotated[bool, Field(description="True blocks the balance keys (ESC O), False unblocks (ESC R)")],
|
|
282
|
+
) -> str:
|
|
283
|
+
"""Block or unblock the balance keys, e.g. so nobody tares by accident during a long logging run."""
|
|
284
|
+
_drv().lock_keys(locked)
|
|
285
|
+
return "Keypad locked." if locked else "Keypad unlocked."
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def main() -> None:
|
|
289
|
+
server.run()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
main()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Wire-level SBI simulator of a Sartorius analytical balance (220 g x 0.1 mg, internal weight).
|
|
2
|
+
|
|
3
|
+
``ESC P`` returns a 22-character line (``fmt=22``: 6-character ID code + 16 characters) or a
|
|
4
|
+
16-character line (``fmt=16``), laid out exactly as in the interface descriptions. While
|
|
5
|
+
the reading is settling (after a load change, tare or zero) the unit symbol is replaced by
|
|
6
|
+
spaces, as real balances do. Control commands are silent; unknown commands are ignored.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
from labmcp import LineSimulator
|
|
16
|
+
|
|
17
|
+
CAPACITY_G = 220.0
|
|
18
|
+
SETTLE_S = 1.5
|
|
19
|
+
ADJUST_S = 8.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SBISimulator(LineSimulator):
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
load_g: float = 52.18734,
|
|
26
|
+
fmt: int = 22,
|
|
27
|
+
seed: int | None = 0,
|
|
28
|
+
time_scale: float = 1.0,
|
|
29
|
+
clock: Callable[[], float] = time.monotonic,
|
|
30
|
+
internal_weight: bool = True,
|
|
31
|
+
adjust_s: float = ADJUST_S,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.rng = random.Random(seed)
|
|
34
|
+
self.fmt = fmt
|
|
35
|
+
self.time_scale = time_scale
|
|
36
|
+
self.clock = clock
|
|
37
|
+
self._t0 = clock()
|
|
38
|
+
self.load_g = load_g # what is physically on the pan
|
|
39
|
+
self.zero_g = 0.0
|
|
40
|
+
self.tare_g = 0.0
|
|
41
|
+
self.internal_weight = internal_weight
|
|
42
|
+
self.adjust_s = adjust_s
|
|
43
|
+
self.keys_locked = False
|
|
44
|
+
self.ambient = "L"
|
|
45
|
+
self._settled_at = 0.0
|
|
46
|
+
self._adjusting_until = -1.0
|
|
47
|
+
self.adjustments = 0
|
|
48
|
+
|
|
49
|
+
# helpers ---------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def now(self) -> float:
|
|
52
|
+
return (self.clock() - self._t0) * self.time_scale
|
|
53
|
+
|
|
54
|
+
def place(self, load_g: float) -> None:
|
|
55
|
+
"""Put a load on the pan (test/demo helper): the reading settles over ~1.5 s."""
|
|
56
|
+
self.load_g = load_g
|
|
57
|
+
self._settled_at = self.now() + SETTLE_S
|
|
58
|
+
|
|
59
|
+
def _net(self) -> float:
|
|
60
|
+
return self.load_g - self.zero_g - self.tare_g
|
|
61
|
+
|
|
62
|
+
def _line(self, body: str, ident: str = "N") -> str:
|
|
63
|
+
return f"{ident:<6}{body}" if self.fmt == 22 else body
|
|
64
|
+
|
|
65
|
+
def _weight_line(self) -> str:
|
|
66
|
+
stable = self.now() >= self._settled_at
|
|
67
|
+
value = self._net()
|
|
68
|
+
if stable:
|
|
69
|
+
value += self.rng.gauss(0, 0.00003)
|
|
70
|
+
else: # settling: noisy, drifting towards the final value
|
|
71
|
+
value += (self._settled_at - self.now()) * 0.002 + self.rng.gauss(0, 0.0003)
|
|
72
|
+
value = round(value, 4)
|
|
73
|
+
sign = "-" if value < 0 else "+"
|
|
74
|
+
unit = "g" if stable else ""
|
|
75
|
+
return self._line(f"{sign} {abs(value):>8.4f} {unit:<3}")
|
|
76
|
+
|
|
77
|
+
def _special(self, text: str, position: int) -> str:
|
|
78
|
+
# Special codes start at a fixed position of the 16-character block; the
|
|
79
|
+
# 22-character format prefixes the ID code "Stat".
|
|
80
|
+
return self._line((" " * (position - 1) + text).ljust(14), ident="Stat")
|
|
81
|
+
|
|
82
|
+
# protocol --------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def handle(self, command: str) -> str | list[str] | None:
|
|
85
|
+
if not command.startswith("\x1b"):
|
|
86
|
+
return None
|
|
87
|
+
cmd = command[1:]
|
|
88
|
+
adjusting = self.now() < self._adjusting_until
|
|
89
|
+
match cmd:
|
|
90
|
+
case "P" | "kP_":
|
|
91
|
+
if adjusting:
|
|
92
|
+
return self._special("Cal.Int.", 4)
|
|
93
|
+
if self.load_g - self.zero_g > CAPACITY_G * 1.02:
|
|
94
|
+
return self._special("High", 7)
|
|
95
|
+
if self.load_g - self.zero_g < -CAPACITY_G * 0.02:
|
|
96
|
+
return self._special("Low", 7)
|
|
97
|
+
return self._weight_line()
|
|
98
|
+
case "T": # tare or zero: zeroes within the zero range, tares otherwise
|
|
99
|
+
if abs(self.load_g - self.zero_g) <= CAPACITY_G * 0.02:
|
|
100
|
+
self.zero_g, self.tare_g = self.load_g, 0.0
|
|
101
|
+
else:
|
|
102
|
+
self.tare_g = self.load_g - self.zero_g
|
|
103
|
+
self._settled_at = self.now() + 0.5
|
|
104
|
+
case "U":
|
|
105
|
+
self.tare_g = self.load_g - self.zero_g
|
|
106
|
+
self._settled_at = self.now() + 0.5
|
|
107
|
+
case "V": # zero key: only within the zero-setting range (here +-2 % of capacity)
|
|
108
|
+
if abs(self.load_g - self.zero_g) <= CAPACITY_G * 0.02:
|
|
109
|
+
self.zero_g, self.tare_g = self.load_g, 0.0
|
|
110
|
+
self._settled_at = self.now() + 0.5
|
|
111
|
+
case "Z":
|
|
112
|
+
if self.internal_weight:
|
|
113
|
+
self._adjusting_until = self.now() + self.adjust_s
|
|
114
|
+
self.adjustments += 1
|
|
115
|
+
case "K" | "L" | "M" | "N":
|
|
116
|
+
self.ambient = cmd
|
|
117
|
+
case "O":
|
|
118
|
+
self.keys_locked = True
|
|
119
|
+
case "R":
|
|
120
|
+
self.keys_locked = False
|
|
121
|
+
case "x1_":
|
|
122
|
+
return "QUINTIX224-1S"
|
|
123
|
+
case "x2_":
|
|
124
|
+
return "0037402012"
|
|
125
|
+
case "x3_":
|
|
126
|
+
return "00-20-12.01"
|
|
127
|
+
return None
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import labmcp_sartorius.server as server_module
|
|
2
|
+
import pytest
|
|
3
|
+
from labmcp import InstrumentProtocolError, InstrumentTimeout, SafetyLimitError, SimulatedTransport
|
|
4
|
+
from labmcp.testing import simulated_client, tool_names
|
|
5
|
+
from labmcp_sartorius.driver import BalanceBusy, SBIBalance, parse_sbi_line
|
|
6
|
+
from labmcp_sartorius.server import server
|
|
7
|
+
from labmcp_sartorius.simulator import SBISimulator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture(autouse=True)
|
|
11
|
+
def _default_options():
|
|
12
|
+
server.configure(options={}) # --option values persist between configure() calls
|
|
13
|
+
yield
|
|
14
|
+
server.configure(options={})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RecordingSimulator(SBISimulator):
|
|
18
|
+
def __init__(self, **kwargs) -> None:
|
|
19
|
+
super().__init__(**kwargs)
|
|
20
|
+
self.received: list[str] = []
|
|
21
|
+
|
|
22
|
+
def handle(self, command: str):
|
|
23
|
+
self.received.append(command)
|
|
24
|
+
return super().handle(command)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def make_driver(legacy: bool = False, **kwargs) -> tuple[SBIBalance, RecordingSimulator]:
|
|
28
|
+
kwargs.setdefault("time_scale", 10.0) # settle in 0.15 s instead of 1.5 s
|
|
29
|
+
sim = RecordingSimulator(**kwargs)
|
|
30
|
+
t = SimulatedTransport(sim, read_termination="\r\n", write_termination="\r\n")
|
|
31
|
+
return SBIBalance(t, legacy=legacy), sim
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------- SBI line parsing
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.parametrize(
|
|
38
|
+
"line, value, unit, stable, ident",
|
|
39
|
+
[
|
|
40
|
+
("+ 123.56 g ", 123.56, "g", True, ""), # 16 characters, stable
|
|
41
|
+
("+ 123.56 ", 123.56, None, False, ""), # no unit symbol = not stable
|
|
42
|
+
("- 0.0012 g ", -0.0012, "g", True, ""),
|
|
43
|
+
(" 111.255 g ", 111.255, "g", True, ""), # position 1 blank = positive
|
|
44
|
+
("N + 123.56 g ", 123.56, "g", True, "N"), # 22 characters with ID code
|
|
45
|
+
("G# + 52.1873 mg ", 52.1873, "mg", True, "G#"),
|
|
46
|
+
("Qnt + 253 pcs", 253, "pcs", True, "Qnt"),
|
|
47
|
+
("N + 123.56 g", 123.56, "g", True, "N"), # Cubis II "one line with full length"
|
|
48
|
+
("+ 123,56 g ", 123.56, "g", True, ""), # decimal comma
|
|
49
|
+
],
|
|
50
|
+
)
|
|
51
|
+
def test_parse_weight_lines(line, value, unit, stable, ident):
|
|
52
|
+
r = parse_sbi_line(line)
|
|
53
|
+
assert (r.value, r.unit, r.stable, r.ident) == (pytest.approx(value), unit, stable, ident)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize(
|
|
57
|
+
"line, match",
|
|
58
|
+
[
|
|
59
|
+
(" High ", "overloaded"),
|
|
60
|
+
("Stat High ", "overloaded"),
|
|
61
|
+
(" Low ", "underload"),
|
|
62
|
+
(" Err 101 ", "error 101"),
|
|
63
|
+
("Stat ERR 320 ", "error 320"),
|
|
64
|
+
(" APP.ERR ", "application error"),
|
|
65
|
+
("garbage", "Unexpected SBI data"),
|
|
66
|
+
],
|
|
67
|
+
)
|
|
68
|
+
def test_parse_special_and_error_lines(line, match):
|
|
69
|
+
with pytest.raises(InstrumentProtocolError, match=match):
|
|
70
|
+
parse_sbi_line(line)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_calibration_status_is_busy():
|
|
74
|
+
with pytest.raises(BalanceBusy):
|
|
75
|
+
parse_sbi_line("Stat Cal.Int. ")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------- driver against the simulator
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_commands_are_esc_sequences():
|
|
82
|
+
bal, sim = make_driver()
|
|
83
|
+
bal.print_reading()
|
|
84
|
+
bal.tare()
|
|
85
|
+
bal.zero()
|
|
86
|
+
bal.set_ambient_filter("unstable")
|
|
87
|
+
bal.lock_keys(True)
|
|
88
|
+
assert sim.received == ["\x1bP", "\x1bU", "\x1bV", "\x1bM", "\x1bO"]
|
|
89
|
+
assert bal.t.write_termination == "\r\n"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_stable_weight_and_unit_memory():
|
|
93
|
+
bal, sim = make_driver()
|
|
94
|
+
w = bal.weight(stable=True)
|
|
95
|
+
assert w.value == pytest.approx(52.1873, abs=2e-4) and w.unit == "g" and w.stable
|
|
96
|
+
assert w.ident == "N"
|
|
97
|
+
sim.place(80.0)
|
|
98
|
+
w = bal.weight(stable=False)
|
|
99
|
+
assert not w.stable and w.unit is None and bal.last_unit == "g"
|
|
100
|
+
w = bal.weight(stable=True, timeout=5)
|
|
101
|
+
assert w.value == pytest.approx(80.0, abs=2e-4) and w.stable
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_16_character_format():
|
|
105
|
+
bal, _ = make_driver(fmt=16)
|
|
106
|
+
w = bal.weight()
|
|
107
|
+
assert w.ident == "" and w.unit == "g"
|
|
108
|
+
assert len(bal.t.simulator._weight_line()) == 14 # 16 characters incl. CR LF
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_tare_and_zero():
|
|
112
|
+
bal, sim = make_driver(load_g=25.0)
|
|
113
|
+
bal.tare()
|
|
114
|
+
assert bal.weight().value == pytest.approx(0, abs=2e-4)
|
|
115
|
+
sim.place(30.5)
|
|
116
|
+
assert bal.weight(timeout=5).value == pytest.approx(5.5, abs=2e-4)
|
|
117
|
+
sim.place(0.001) # container removed, pan nearly empty
|
|
118
|
+
bal.zero()
|
|
119
|
+
assert bal.weight(timeout=5).value == pytest.approx(0, abs=2e-4)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_overload_raises():
|
|
123
|
+
bal, sim = make_driver()
|
|
124
|
+
sim.place(500)
|
|
125
|
+
with pytest.raises(InstrumentProtocolError, match="overloaded"):
|
|
126
|
+
bal.weight(stable=False)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_no_reply_gives_configuration_hint():
|
|
130
|
+
bal, _ = make_driver()
|
|
131
|
+
with pytest.raises(InstrumentTimeout, match="set to SBI"):
|
|
132
|
+
bal._lines("Y", timeout=0.3)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_identify():
|
|
136
|
+
bal, _ = make_driver()
|
|
137
|
+
assert bal.identify() == {
|
|
138
|
+
"manufacturer": "Sartorius",
|
|
139
|
+
"protocol": "SBI",
|
|
140
|
+
"model": "QUINTIX224-1S",
|
|
141
|
+
"serial": "0037402012",
|
|
142
|
+
"software": "00-20-12.01",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_legacy_mode_uses_esc_t():
|
|
147
|
+
bal, sim = make_driver(legacy=True, load_g=12.0)
|
|
148
|
+
bal.tare()
|
|
149
|
+
assert sim.received[-1] == "\x1bT"
|
|
150
|
+
with pytest.raises(InstrumentProtocolError, match="no separate zero"):
|
|
151
|
+
bal.zero()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_unstable_timeout():
|
|
155
|
+
bal, sim = make_driver(time_scale=1.0)
|
|
156
|
+
sim.place(10.0)
|
|
157
|
+
with pytest.raises(InstrumentProtocolError, match="did not report a stable reading"):
|
|
158
|
+
bal.weight(stable=True, timeout=0.5)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------- MCP level
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def test_tools_via_mcp():
|
|
165
|
+
async with simulated_client(server) as client:
|
|
166
|
+
info = (await client.call_tool("get_connection_info", {})).data
|
|
167
|
+
assert info["simulated"] is True and info["connected"] is True
|
|
168
|
+
assert info["instrument"]["model"] == "QUINTIX224-1S"
|
|
169
|
+
|
|
170
|
+
reading = (await client.call_tool("read_weight", {"stable": True})).structured_content
|
|
171
|
+
assert reading["unit"] == "g" and reading["stable"] is True
|
|
172
|
+
assert reading["value"] == pytest.approx(52.18734, abs=2e-4)
|
|
173
|
+
|
|
174
|
+
tared = (await client.call_tool("tare", {})).structured_content
|
|
175
|
+
assert tared["value"] == pytest.approx(0, abs=2e-4)
|
|
176
|
+
|
|
177
|
+
series = (
|
|
178
|
+
await client.call_tool("log_weight_series", {"count": 3, "interval_s": 0.5})
|
|
179
|
+
).structured_content
|
|
180
|
+
assert series["count"] == 3 and series["unit"] == "g"
|
|
181
|
+
|
|
182
|
+
await client.call_tool("set_ambient_conditions", {"conditions": "very_unstable"})
|
|
183
|
+
await client.call_tool("lock_keypad", {"locked": True})
|
|
184
|
+
sim = server.driver.t.simulator
|
|
185
|
+
assert sim.ambient == "N" and sim.keys_locked
|
|
186
|
+
|
|
187
|
+
log = (await client.call_tool("get_command_log", {"limit": 100})).data
|
|
188
|
+
assert any(entry["data"].startswith("<ESC>P") for entry in log) # control bytes are named
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def test_zero_outside_range_is_reported():
|
|
192
|
+
async with simulated_client(server) as client:
|
|
193
|
+
with pytest.raises(Exception, match="zero-setting range"):
|
|
194
|
+
await client.call_tool("zero", {"timeout_s": 1})
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def test_internal_adjustment(monkeypatch):
|
|
198
|
+
monkeypatch.setattr(server_module, "ADJUST_POLL_S", 0.05)
|
|
199
|
+
async with simulated_client(server) as client:
|
|
200
|
+
server.driver.t.simulator.adjust_s = 0.5
|
|
201
|
+
result = (await client.call_tool("run_internal_adjustment", {"timeout_s": 30})).structured_content
|
|
202
|
+
assert result["observed_adjustment"] is True
|
|
203
|
+
assert result["final_reading"]["stable"] is True
|
|
204
|
+
assert server.driver.t.simulator.adjustments == 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def test_internal_adjustment_not_supported(monkeypatch):
|
|
208
|
+
monkeypatch.setattr(server_module, "ADJUST_POLL_S", 0.05)
|
|
209
|
+
monkeypatch.setattr(server_module, "ADJUST_NO_SIGN_S", 0.5)
|
|
210
|
+
async with simulated_client(server) as client:
|
|
211
|
+
server.driver.t.simulator.internal_weight = False
|
|
212
|
+
result = (await client.call_tool("run_internal_adjustment", {"timeout_s": 30})).structured_content
|
|
213
|
+
assert result["observed_adjustment"] is False
|
|
214
|
+
assert "No sign of an adjustment" in result["message"]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def test_legacy_option_via_mcp():
|
|
218
|
+
async with simulated_client(server, options={"legacy": "true"}) as client:
|
|
219
|
+
tared = (await client.call_tool("tare", {})).structured_content # ESC T tares a loaded pan
|
|
220
|
+
assert tared["value"] == pytest.approx(0, abs=2e-4)
|
|
221
|
+
with pytest.raises(Exception, match="no separate zero"):
|
|
222
|
+
await client.call_tool("zero", {})
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def test_read_only_hides_control_tools():
|
|
226
|
+
async with simulated_client(server, read_only=True) as client:
|
|
227
|
+
names = await tool_names(client)
|
|
228
|
+
assert {"read_weight", "log_weight_series", "reconnect"} <= names
|
|
229
|
+
for hidden in ("tare", "zero", "run_internal_adjustment", "set_ambient_conditions", "lock_keypad"):
|
|
230
|
+
assert hidden not in names
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def test_series_duration_limit():
|
|
234
|
+
async with simulated_client(server, limits={"max_series_duration_s": 1}) as client:
|
|
235
|
+
with pytest.raises(Exception, match="max_series_duration_s"):
|
|
236
|
+
await client.call_tool("log_weight_series", {"count": 5, "interval_s": 1})
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_limit_error_type():
|
|
240
|
+
server.configure(simulate=True, limits={"max_series_duration_s": 1})
|
|
241
|
+
with pytest.raises(SafetyLimitError):
|
|
242
|
+
server.check("max_series_duration_s", 5)
|