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.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .DS_Store
10
+ *.jsonl
11
+ !**/fixtures/*.jsonl
12
+ .idea/
13
+ .vscode/
14
+
15
+ CLAUDE.md
@@ -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)