mcp-server-senseca-pro 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.
- mcp_server_senseca_pro-0.1.0/PKG-INFO +121 -0
- mcp_server_senseca_pro-0.1.0/README.md +94 -0
- mcp_server_senseca_pro-0.1.0/pyproject.toml +50 -0
- mcp_server_senseca_pro-0.1.0/pyproject.toml.orig +45 -0
- mcp_server_senseca_pro-0.1.0/src/mcp_server_senseca_pro/__init__.py +5 -0
- mcp_server_senseca_pro-0.1.0/src/mcp_server_senseca_pro/__main__.py +3 -0
- mcp_server_senseca_pro-0.1.0/src/mcp_server_senseca_pro/device.py +274 -0
- mcp_server_senseca_pro-0.1.0/src/mcp_server_senseca_pro/server.py +165 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-server-senseca-pro
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial MCP server for Senseca PRO 111 / PRO 115 Pt100 thermometers
|
|
5
|
+
Keywords: mcp,senseca,pt100,thermometer,serial
|
|
6
|
+
Author: shaag7967
|
|
7
|
+
Author-email: shaag7967 <shaag@gmx.net>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering
|
|
19
|
+
Classifier: Topic :: System :: Hardware
|
|
20
|
+
Requires-Dist: mcp>=2.3.0
|
|
21
|
+
Requires-Dist: pyserial>=3.5
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Project-URL: Repository, https://github.com/shaag7967/mcp-server-senseca-pro
|
|
24
|
+
Project-URL: Issues, https://github.com/shaag7967/mcp-server-senseca-pro/issues
|
|
25
|
+
Project-URL: Changelog, https://github.com/shaag7967/mcp-server-senseca-pro/releases
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# mcp-server-senseca-pro
|
|
29
|
+
|
|
30
|
+
An [MCP](https://modelcontextprotocol.io) server that lets AI assistants read live measurements
|
|
31
|
+
from a **Senseca PRO 111 / PRO 115** handheld Pt100 thermometer connected via USB.
|
|
32
|
+
|
|
33
|
+
> Unofficial project, not affiliated with or endorsed by Senseca.
|
|
34
|
+
> Tested with a PRO 111, firmware V1.4.3.0, interface protocol V1.0.0.1.
|
|
35
|
+
|
|
36
|
+
## Tools
|
|
37
|
+
|
|
38
|
+
| Tool | Description |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `read_temperature` | Current temperature with unit (°C/°F/K, as set on the instrument) and UTC timestamp |
|
|
41
|
+
| `read_resistance` | Current Pt100 resistance in ohm |
|
|
42
|
+
| `sample_temperature` | Readings over a period (max 600 s, interval ≥ 0.5 s) with min/max/mean/stdev |
|
|
43
|
+
| `get_device_info` | Model, serial number, firmware, instrument clock, battery level |
|
|
44
|
+
|
|
45
|
+
With `--allow-write` two more tools are available:
|
|
46
|
+
|
|
47
|
+
| Tool | Description |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `set_device_clock` | Set the instrument clock (default: this computer's time) |
|
|
50
|
+
| `set_location_name` | Rename one of the 20 location labels |
|
|
51
|
+
|
|
52
|
+
The server cannot change calibration or adjustment data; the instrument does not offer that
|
|
53
|
+
over its serial interface.
|
|
54
|
+
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
- Python ≥ 3.10 and [uv](https://docs.astral.sh/uv/)
|
|
58
|
+
- The instrument switched on and connected via USB-C. On the PRO 115 set
|
|
59
|
+
*Settings → USB mode* to **COM interface**.
|
|
60
|
+
- **Linux:** your user must be in the `dialout` group (`sudo usermod -aG dialout $USER`, then log in again).
|
|
61
|
+
- **Windows / macOS:** no extra setup; the port shows up as `COMx` or `/dev/cu.usbmodem…`.
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
### Claude Code
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
claude mcp add senseca-pro -- uvx mcp-server-senseca-pro
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Claude Desktop and other clients
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"mcpServers": {
|
|
76
|
+
"senseca-pro": {
|
|
77
|
+
"command": "uvx",
|
|
78
|
+
"args": ["mcp-server-senseca-pro"]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Options
|
|
85
|
+
|
|
86
|
+
| Option | Environment variable | Default |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `--port PORT` | `SENSECA_PORT` | auto-detect |
|
|
89
|
+
| `--allow-write` | `SENSECA_ALLOW_WRITE=1` | off |
|
|
90
|
+
| `--log-level LEVEL` | `SENSECA_LOG_LEVEL` | `WARNING` |
|
|
91
|
+
| — | `SENSECA_PIN` | none; needed only if the instrument's protected mode is on |
|
|
92
|
+
|
|
93
|
+
Auto-detection looks for USB ID `0483:5740` and only accepts a port whose device reports
|
|
94
|
+
manufacturer `senseca`. If several instruments are connected, set the port explicitly; on Linux
|
|
95
|
+
prefer the stable `/dev/serial/by-id/…` path.
|
|
96
|
+
|
|
97
|
+
Only one program can use the serial port at a time. Close ProXware or terminal programs while
|
|
98
|
+
the server is running.
|
|
99
|
+
|
|
100
|
+
## Measurement notes
|
|
101
|
+
|
|
102
|
+
- Instrument accuracy: ±0.05 °C or ±0.06 % of reading (whichever is higher) ±1 digit, probe not included.
|
|
103
|
+
- The USB port is **not galvanically isolated**. With a probe touching a grounded medium, the
|
|
104
|
+
USB connection can shift the reading. Compare readings with and without USB, and use a USB
|
|
105
|
+
isolator if they differ (see the instrument manual, section 7.2).
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
uv sync
|
|
111
|
+
uv run pytest # tests use a simulated instrument
|
|
112
|
+
npx @modelcontextprotocol/inspector uv run mcp-server-senseca-pro # try the tools interactively
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The serial protocol is described in [docs/protocol.md](https://github.com/shaag7967/mcp-server-senseca-pro/blob/main/docs/protocol.md).
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
120
|
+
|
|
121
|
+
<!-- mcp-name: io.github.shaag7967/mcp-server-senseca-pro -->
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# mcp-server-senseca-pro
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server that lets AI assistants read live measurements
|
|
4
|
+
from a **Senseca PRO 111 / PRO 115** handheld Pt100 thermometer connected via USB.
|
|
5
|
+
|
|
6
|
+
> Unofficial project, not affiliated with or endorsed by Senseca.
|
|
7
|
+
> Tested with a PRO 111, firmware V1.4.3.0, interface protocol V1.0.0.1.
|
|
8
|
+
|
|
9
|
+
## Tools
|
|
10
|
+
|
|
11
|
+
| Tool | Description |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `read_temperature` | Current temperature with unit (°C/°F/K, as set on the instrument) and UTC timestamp |
|
|
14
|
+
| `read_resistance` | Current Pt100 resistance in ohm |
|
|
15
|
+
| `sample_temperature` | Readings over a period (max 600 s, interval ≥ 0.5 s) with min/max/mean/stdev |
|
|
16
|
+
| `get_device_info` | Model, serial number, firmware, instrument clock, battery level |
|
|
17
|
+
|
|
18
|
+
With `--allow-write` two more tools are available:
|
|
19
|
+
|
|
20
|
+
| Tool | Description |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `set_device_clock` | Set the instrument clock (default: this computer's time) |
|
|
23
|
+
| `set_location_name` | Rename one of the 20 location labels |
|
|
24
|
+
|
|
25
|
+
The server cannot change calibration or adjustment data; the instrument does not offer that
|
|
26
|
+
over its serial interface.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Python ≥ 3.10 and [uv](https://docs.astral.sh/uv/)
|
|
31
|
+
- The instrument switched on and connected via USB-C. On the PRO 115 set
|
|
32
|
+
*Settings → USB mode* to **COM interface**.
|
|
33
|
+
- **Linux:** your user must be in the `dialout` group (`sudo usermod -aG dialout $USER`, then log in again).
|
|
34
|
+
- **Windows / macOS:** no extra setup; the port shows up as `COMx` or `/dev/cu.usbmodem…`.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
### Claude Code
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
claude mcp add senseca-pro -- uvx mcp-server-senseca-pro
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Claude Desktop and other clients
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"senseca-pro": {
|
|
50
|
+
"command": "uvx",
|
|
51
|
+
"args": ["mcp-server-senseca-pro"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Options
|
|
58
|
+
|
|
59
|
+
| Option | Environment variable | Default |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `--port PORT` | `SENSECA_PORT` | auto-detect |
|
|
62
|
+
| `--allow-write` | `SENSECA_ALLOW_WRITE=1` | off |
|
|
63
|
+
| `--log-level LEVEL` | `SENSECA_LOG_LEVEL` | `WARNING` |
|
|
64
|
+
| — | `SENSECA_PIN` | none; needed only if the instrument's protected mode is on |
|
|
65
|
+
|
|
66
|
+
Auto-detection looks for USB ID `0483:5740` and only accepts a port whose device reports
|
|
67
|
+
manufacturer `senseca`. If several instruments are connected, set the port explicitly; on Linux
|
|
68
|
+
prefer the stable `/dev/serial/by-id/…` path.
|
|
69
|
+
|
|
70
|
+
Only one program can use the serial port at a time. Close ProXware or terminal programs while
|
|
71
|
+
the server is running.
|
|
72
|
+
|
|
73
|
+
## Measurement notes
|
|
74
|
+
|
|
75
|
+
- Instrument accuracy: ±0.05 °C or ±0.06 % of reading (whichever is higher) ±1 digit, probe not included.
|
|
76
|
+
- The USB port is **not galvanically isolated**. With a probe touching a grounded medium, the
|
|
77
|
+
USB connection can shift the reading. Compare readings with and without USB, and use a USB
|
|
78
|
+
isolator if they differ (see the instrument manual, section 7.2).
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv sync
|
|
84
|
+
uv run pytest # tests use a simulated instrument
|
|
85
|
+
npx @modelcontextprotocol/inspector uv run mcp-server-senseca-pro # try the tools interactively
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The serial protocol is described in [docs/protocol.md](https://github.com/shaag7967/mcp-server-senseca-pro/blob/main/docs/protocol.md).
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
|
93
|
+
|
|
94
|
+
<!-- mcp-name: io.github.shaag7967/mcp-server-senseca-pro -->
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-server-senseca-pro"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Unofficial MCP server for Senseca PRO 111 / PRO 115 Pt100 thermometers"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
keywords = [
|
|
9
|
+
"mcp",
|
|
10
|
+
"senseca",
|
|
11
|
+
"pt100",
|
|
12
|
+
"thermometer",
|
|
13
|
+
"serial",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Topic :: Scientific/Engineering",
|
|
26
|
+
"Topic :: System :: Hardware",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"mcp>=2.3.0",
|
|
30
|
+
"pyserial>=3.5",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[[project.authors]]
|
|
34
|
+
name = "shaag7967"
|
|
35
|
+
email = "shaag@gmx.net"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Repository = "https://github.com/shaag7967/mcp-server-senseca-pro"
|
|
39
|
+
Issues = "https://github.com/shaag7967/mcp-server-senseca-pro/issues"
|
|
40
|
+
Changelog = "https://github.com/shaag7967/mcp-server-senseca-pro/releases"
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
mcp-server-senseca-pro = "mcp_server_senseca_pro:main"
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["uv_build>=0.12.24,<0.13.0"]
|
|
47
|
+
build-backend = "uv_build"
|
|
48
|
+
|
|
49
|
+
[dependency-groups]
|
|
50
|
+
dev = ["pytest>=9.2.0"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-server-senseca-pro"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Unofficial MCP server for Senseca PRO 111 / PRO 115 Pt100 thermometers"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "shaag7967", email = "shaag@gmx.net" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
keywords = ["mcp", "senseca", "pt100", "thermometer", "serial"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Topic :: Scientific/Engineering",
|
|
23
|
+
"Topic :: System :: Hardware",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"mcp>=2.3.0",
|
|
27
|
+
"pyserial>=3.5",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Repository = "https://github.com/shaag7967/mcp-server-senseca-pro"
|
|
32
|
+
Issues = "https://github.com/shaag7967/mcp-server-senseca-pro/issues"
|
|
33
|
+
Changelog = "https://github.com/shaag7967/mcp-server-senseca-pro/releases"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
mcp-server-senseca-pro = "mcp_server_senseca_pro:main"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["uv_build>=0.12.24,<0.13.0"]
|
|
40
|
+
build-backend = "uv_build"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=9.2.0",
|
|
45
|
+
]
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Serial driver for Senseca PRO 111 / PRO 115 Pt100 thermometers.
|
|
2
|
+
|
|
3
|
+
The instrument shows up as a USB CDC virtual COM port (115200 8N1). Commands are
|
|
4
|
+
ASCII lines of the form ``Command: argument`` terminated by CRLF; every reply is
|
|
5
|
+
one or more CRLF-terminated lines followed by an empty line.
|
|
6
|
+
|
|
7
|
+
This module does not depend on MCP so it can be used and tested on its own.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any, Callable, Protocol
|
|
19
|
+
|
|
20
|
+
import serial
|
|
21
|
+
from serial.tools import list_ports
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
BAUDRATE = 115200
|
|
26
|
+
# Generic STMicroelectronics Virtual COM Port ID. Many other devices use it too,
|
|
27
|
+
# so candidates are only accepted after the manufacturer query answers "senseca".
|
|
28
|
+
USB_VID = 0x0483
|
|
29
|
+
USB_PID = 0x5740
|
|
30
|
+
|
|
31
|
+
REPLY_TERMINATOR = b"\r\n\r\n"
|
|
32
|
+
|
|
33
|
+
# Channel layout observed on PRO 111 firmware V1.4.3.0.
|
|
34
|
+
BATTERY_CHANNEL = (0, None)
|
|
35
|
+
RESISTANCE_CHANNEL = (1, None)
|
|
36
|
+
TEMPERATURE_CHANNEL = (1, 1)
|
|
37
|
+
|
|
38
|
+
# The serial interface relocks after 2 minutes without commands; resend the PIN before that.
|
|
39
|
+
PIN_REFRESH_S = 90.0
|
|
40
|
+
|
|
41
|
+
_RANGE_STATES = {"underflow", "overflow"}
|
|
42
|
+
_DATETIME_RE = re.compile(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?:\.(\d+))?")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DeviceError(Exception):
|
|
46
|
+
"""Base class for all errors raised by this module."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DeviceNotFoundError(DeviceError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CommandError(DeviceError):
|
|
54
|
+
"""The instrument answered with ``error: ...``."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OutOfRangeError(DeviceError):
|
|
58
|
+
"""The instrument reported Underflow/Overflow, e.g. no probe connected."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SerialLike(Protocol):
|
|
62
|
+
def write(self, data: bytes) -> int | None: ...
|
|
63
|
+
def read_until(self, expected: bytes = ..., size: int | None = ...) -> bytes: ...
|
|
64
|
+
def reset_input_buffer(self) -> None: ...
|
|
65
|
+
def reset_output_buffer(self) -> None: ...
|
|
66
|
+
def close(self) -> None: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
Opener = Callable[[str], SerialLike]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _open_serial(port: str) -> serial.Serial:
|
|
73
|
+
return serial.Serial(port, BAUDRATE, timeout=2.0, write_timeout=2.0, exclusive=True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Reading:
|
|
78
|
+
value: float
|
|
79
|
+
unit: str
|
|
80
|
+
timestamp: datetime
|
|
81
|
+
|
|
82
|
+
def as_dict(self) -> dict[str, Any]:
|
|
83
|
+
return {"value": self.value, "unit": self.unit, "timestamp": self.timestamp.isoformat()}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Pro11x:
|
|
87
|
+
"""Thread-safe connection to one PRO 11x instrument.
|
|
88
|
+
|
|
89
|
+
The port is opened lazily and reopened once if a command fails because the
|
|
90
|
+
device was unplugged or switched off in the meantime.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, port: str | None = None, pin: str | None = None, opener: Opener = _open_serial):
|
|
94
|
+
self._port = port
|
|
95
|
+
# An auto-detected port is searched again after a disconnect, since the
|
|
96
|
+
# instrument may come back under a different device name.
|
|
97
|
+
self._auto_detect = port is None
|
|
98
|
+
self._pin = f"{int(pin):04d}" if pin else None
|
|
99
|
+
self._opener = opener
|
|
100
|
+
self._conn: SerialLike | None = None
|
|
101
|
+
self._lock = threading.Lock()
|
|
102
|
+
self._last_command: float | None = None # monotonic time, None = nothing sent yet
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def port(self) -> str | None:
|
|
106
|
+
return self._port
|
|
107
|
+
|
|
108
|
+
def close(self) -> None:
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._disconnect()
|
|
111
|
+
|
|
112
|
+
def __enter__(self) -> Pro11x:
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def __exit__(self, *exc: object) -> None:
|
|
116
|
+
self.close()
|
|
117
|
+
|
|
118
|
+
# -- low level -----------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def query(self, command: str) -> list[str]:
|
|
121
|
+
"""Send one command and return the non-empty reply lines."""
|
|
122
|
+
with self._lock:
|
|
123
|
+
try:
|
|
124
|
+
return self._query_locked(command)
|
|
125
|
+
except serial.SerialTimeoutException as e:
|
|
126
|
+
# The port is fine but the firmware does not take data; reopening will not help.
|
|
127
|
+
self._disconnect()
|
|
128
|
+
raise DeviceError(
|
|
129
|
+
f"Instrument on {self._port} does not respond. Check that it is switched on; "
|
|
130
|
+
"if it is, unplug and reconnect the USB cable."
|
|
131
|
+
) from e
|
|
132
|
+
except (serial.SerialException, OSError) as e:
|
|
133
|
+
log.warning("Serial error (%s), reconnecting", e)
|
|
134
|
+
self._disconnect()
|
|
135
|
+
try:
|
|
136
|
+
return self._query_locked(command)
|
|
137
|
+
except (serial.SerialException, OSError) as e2:
|
|
138
|
+
self._disconnect()
|
|
139
|
+
raise DeviceError(f"Communication with {self._port} failed: {e2}") from e2
|
|
140
|
+
|
|
141
|
+
def _query_locked(self, command: str) -> list[str]:
|
|
142
|
+
conn = self._connect()
|
|
143
|
+
if self._pin and (self._last_command is None or time.monotonic() - self._last_command > PIN_REFRESH_S):
|
|
144
|
+
self._exchange(conn, f"setPINCode: {self._pin}")
|
|
145
|
+
return self._exchange(conn, command)
|
|
146
|
+
|
|
147
|
+
def _exchange(self, conn: SerialLike, command: str) -> list[str]:
|
|
148
|
+
conn.reset_input_buffer()
|
|
149
|
+
conn.write(command.encode("ascii") + b"\r\n")
|
|
150
|
+
raw = conn.read_until(REPLY_TERMINATOR)
|
|
151
|
+
self._last_command = time.monotonic()
|
|
152
|
+
if not raw.endswith(REPLY_TERMINATOR):
|
|
153
|
+
raise DeviceError(f"Timeout waiting for reply to {command!r} (got {raw!r})")
|
|
154
|
+
lines = [line.strip() for line in raw.decode("utf-8", errors="replace").splitlines()]
|
|
155
|
+
lines = [line for line in lines if line]
|
|
156
|
+
if lines and lines[0].lower().startswith("error:"):
|
|
157
|
+
raise CommandError(f"{command!r}: {lines[0][6:].strip()}")
|
|
158
|
+
return lines
|
|
159
|
+
|
|
160
|
+
def _connect(self) -> SerialLike:
|
|
161
|
+
if self._conn is None:
|
|
162
|
+
if self._port is None:
|
|
163
|
+
self._port = find_port(self._opener)
|
|
164
|
+
log.info("Opening %s", self._port)
|
|
165
|
+
try:
|
|
166
|
+
self._conn = self._opener(self._port)
|
|
167
|
+
except (serial.SerialException, OSError) as e:
|
|
168
|
+
raise DeviceNotFoundError(f"Cannot open {self._port}: {e}") from e
|
|
169
|
+
self._last_command = None
|
|
170
|
+
return self._conn
|
|
171
|
+
|
|
172
|
+
def _disconnect(self) -> None:
|
|
173
|
+
if self._conn is not None:
|
|
174
|
+
try:
|
|
175
|
+
# Drop unsent data, otherwise close() blocks up to 30 s (tty closing_wait).
|
|
176
|
+
self._conn.reset_output_buffer()
|
|
177
|
+
self._conn.close()
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
self._conn = None
|
|
181
|
+
if self._auto_detect:
|
|
182
|
+
self._port = None
|
|
183
|
+
|
|
184
|
+
def _single(self, command: str) -> str:
|
|
185
|
+
lines = self.query(command)
|
|
186
|
+
if not lines:
|
|
187
|
+
raise DeviceError(f"Empty reply to {command!r}")
|
|
188
|
+
return lines[0]
|
|
189
|
+
|
|
190
|
+
# -- commands ------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _channel_arg(channel: int, sub: int | None) -> str:
|
|
194
|
+
return f"{channel}" if sub is None else f"{channel}, {sub}"
|
|
195
|
+
|
|
196
|
+
def value(self, channel: int, sub: int | None = None) -> float:
|
|
197
|
+
"""Latest measured value of a channel in full resolution."""
|
|
198
|
+
text = self._single(f"GetFullResolutionValue: {self._channel_arg(channel, sub)}")
|
|
199
|
+
if text.lower() in _RANGE_STATES:
|
|
200
|
+
raise OutOfRangeError(f"Measurement {text} (probe missing, broken or out of range)")
|
|
201
|
+
try:
|
|
202
|
+
return float(text)
|
|
203
|
+
except ValueError:
|
|
204
|
+
raise DeviceError(f"Unexpected measurement reply: {text!r}") from None
|
|
205
|
+
|
|
206
|
+
def unit(self, channel: int, sub: int | None = None) -> str:
|
|
207
|
+
return self._single(f"GetChannelUnit: {self._channel_arg(channel, sub)}")
|
|
208
|
+
|
|
209
|
+
def reading(self, channel: int, sub: int | None = None) -> Reading:
|
|
210
|
+
return Reading(self.value(channel, sub), self.unit(channel, sub), datetime.now(timezone.utc))
|
|
211
|
+
|
|
212
|
+
def temperature(self) -> Reading:
|
|
213
|
+
return self.reading(*TEMPERATURE_CHANNEL)
|
|
214
|
+
|
|
215
|
+
def resistance(self) -> Reading:
|
|
216
|
+
return self.reading(*RESISTANCE_CHANNEL)
|
|
217
|
+
|
|
218
|
+
def battery(self) -> Reading:
|
|
219
|
+
return self.reading(*BATTERY_CHANNEL)
|
|
220
|
+
|
|
221
|
+
def manufacturer(self) -> str:
|
|
222
|
+
return self._single("GetDeviceManufacturer: 0")
|
|
223
|
+
|
|
224
|
+
def device_information(self) -> dict[str, str]:
|
|
225
|
+
info: dict[str, str] = {}
|
|
226
|
+
for line in self.query("GetDeviceInformation: 0"):
|
|
227
|
+
key, sep, value = line.partition(":")
|
|
228
|
+
if sep:
|
|
229
|
+
info[key.strip().strip('"')] = value.strip().strip('"')
|
|
230
|
+
return info
|
|
231
|
+
|
|
232
|
+
def get_datetime(self) -> datetime:
|
|
233
|
+
"""Instrument clock (the instrument keeps UTC)."""
|
|
234
|
+
text = self._single("GetDateTime: 0")
|
|
235
|
+
m = _DATETIME_RE.search(text)
|
|
236
|
+
if not m:
|
|
237
|
+
raise DeviceError(f"Unexpected date/time reply: {text!r}")
|
|
238
|
+
# Pad the fraction ("08.55") to microseconds; Python 3.10 only parses 3 or 6 digits.
|
|
239
|
+
fraction = (m.group(2) or "").ljust(6, "0")[:6]
|
|
240
|
+
return datetime.fromisoformat(f"{m.group(1)}.{fraction}").replace(tzinfo=timezone.utc)
|
|
241
|
+
|
|
242
|
+
def set_datetime(self, when: datetime) -> list[str]:
|
|
243
|
+
utc = when.astimezone(timezone.utc) if when.tzinfo else when
|
|
244
|
+
return self.query(f"SetDateTime: {utc:%Y-%m-%d %H:%M:%S}")
|
|
245
|
+
|
|
246
|
+
def update_location(self, index: int, name: str) -> list[str]:
|
|
247
|
+
if not 1 <= index <= 20:
|
|
248
|
+
raise ValueError("Location index must be 1..20")
|
|
249
|
+
if len(name) > 16 or not name.isascii() or any(c in name for c in "\r\n"):
|
|
250
|
+
raise ValueError("Location name must be at most 16 ASCII characters on one line")
|
|
251
|
+
return self.query(f"updateLocation: {index}, {name}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def find_port(opener: Opener = _open_serial) -> str:
|
|
255
|
+
"""Return the first serial port that answers like a Senseca instrument."""
|
|
256
|
+
candidates = [p.device for p in list_ports.comports() if p.vid == USB_VID and p.pid == USB_PID]
|
|
257
|
+
if not candidates:
|
|
258
|
+
raise DeviceNotFoundError(
|
|
259
|
+
"No Senseca instrument found. Check the USB cable, that the instrument is on, "
|
|
260
|
+
"and set SENSECA_PORT if auto-detection does not work."
|
|
261
|
+
)
|
|
262
|
+
problems = []
|
|
263
|
+
for device in candidates:
|
|
264
|
+
probe = Pro11x(device, opener=opener)
|
|
265
|
+
try:
|
|
266
|
+
manufacturer = probe.manufacturer()
|
|
267
|
+
if manufacturer.lower() == "senseca":
|
|
268
|
+
return device
|
|
269
|
+
problems.append(f"{device}: manufacturer is {manufacturer!r}")
|
|
270
|
+
except DeviceError as e:
|
|
271
|
+
problems.append(str(e))
|
|
272
|
+
finally:
|
|
273
|
+
probe.close()
|
|
274
|
+
raise DeviceNotFoundError("No Senseca instrument answered. " + " ".join(problems))
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""MCP server exposing a Senseca PRO 111 / PRO 115 thermometer."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import statistics
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from importlib.metadata import version
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from mcp.server.mcpserver import MCPServer
|
|
16
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
17
|
+
from mcp_types import ToolAnnotations
|
|
18
|
+
|
|
19
|
+
from .device import DeviceError, Pro11x
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
ACCURACY = "±0.05 °C or ±0.06 % of reading (whichever is higher) ±1 digit, instrument only"
|
|
24
|
+
MIN_INTERVAL_S = 0.5 # the instrument measures 2 times per second
|
|
25
|
+
MAX_DURATION_S = 600.0
|
|
26
|
+
|
|
27
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=False)
|
|
28
|
+
WRITES_DEVICE = ToolAnnotations(read_only_hint=False, destructive_hint=False, open_world_hint=False)
|
|
29
|
+
|
|
30
|
+
INSTRUCTIONS = """\
|
|
31
|
+
Access to a Senseca PRO 111/115 handheld thermometer with a 4-wire Pt100 probe, connected via USB.
|
|
32
|
+
Readings are live values from the physical instrument. The temperature unit follows the instrument
|
|
33
|
+
setting (°C, °F or K) and is always returned with the value. The instrument updates twice per second.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def _device_errors() -> Iterator[None]:
|
|
39
|
+
# Only ToolError messages reach the model; anything else shows up as a bare crash.
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
except (DeviceError, ValueError) as e:
|
|
43
|
+
raise ToolError(str(e)) from e
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_server(device: Pro11x, allow_write: bool = False) -> MCPServer:
|
|
47
|
+
mcp = MCPServer(
|
|
48
|
+
name="senseca-pro",
|
|
49
|
+
title="Senseca PRO 111/115 thermometer",
|
|
50
|
+
version=version("mcp-server-senseca-pro"),
|
|
51
|
+
instructions=INSTRUCTIONS,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
55
|
+
def read_temperature() -> dict[str, Any]:
|
|
56
|
+
"""Read the current temperature from the Pt100 probe, with unit and UTC timestamp."""
|
|
57
|
+
with _device_errors():
|
|
58
|
+
return device.temperature().as_dict() | {"accuracy": ACCURACY}
|
|
59
|
+
|
|
60
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
61
|
+
def read_resistance() -> dict[str, Any]:
|
|
62
|
+
"""Read the current Pt100 probe resistance in ohm (about 100 ohm at 0 °C)."""
|
|
63
|
+
with _device_errors():
|
|
64
|
+
return device.resistance().as_dict()
|
|
65
|
+
|
|
66
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
67
|
+
def sample_temperature(duration_s: float = 10.0, interval_s: float = 1.0) -> dict[str, Any]:
|
|
68
|
+
"""Record temperature readings over a period and return statistics plus all samples.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
duration_s: Recording time in seconds (max 600).
|
|
72
|
+
interval_s: Time between readings in seconds (min 0.5, the instrument's update rate).
|
|
73
|
+
"""
|
|
74
|
+
if not 0 < duration_s <= MAX_DURATION_S:
|
|
75
|
+
raise ToolError(f"duration_s must be between 0 and {MAX_DURATION_S:g}")
|
|
76
|
+
if interval_s < MIN_INTERVAL_S:
|
|
77
|
+
raise ToolError(f"interval_s must be at least {MIN_INTERVAL_S}")
|
|
78
|
+
samples = []
|
|
79
|
+
with _device_errors():
|
|
80
|
+
start = time.monotonic()
|
|
81
|
+
while True:
|
|
82
|
+
samples.append(device.temperature())
|
|
83
|
+
next_at = start + len(samples) * interval_s
|
|
84
|
+
if next_at - start > duration_s:
|
|
85
|
+
break
|
|
86
|
+
time.sleep(max(0.0, next_at - time.monotonic()))
|
|
87
|
+
values = [s.value for s in samples]
|
|
88
|
+
return {
|
|
89
|
+
"unit": samples[0].unit,
|
|
90
|
+
"count": len(values),
|
|
91
|
+
"min": min(values),
|
|
92
|
+
"max": max(values),
|
|
93
|
+
"mean": round(statistics.fmean(values), 4),
|
|
94
|
+
"stdev": round(statistics.stdev(values), 4) if len(values) > 1 else 0.0,
|
|
95
|
+
"start": samples[0].timestamp.isoformat(),
|
|
96
|
+
"end": samples[-1].timestamp.isoformat(),
|
|
97
|
+
"samples": [{"t": s.timestamp.isoformat(), "value": s.value} for s in samples],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
101
|
+
def get_device_info() -> dict[str, Any]:
|
|
102
|
+
"""Instrument model, serial number, firmware, clock (UTC) and battery level."""
|
|
103
|
+
with _device_errors():
|
|
104
|
+
info: dict[str, Any] = dict(device.device_information())
|
|
105
|
+
info["port"] = device.port
|
|
106
|
+
info["clock_utc"] = device.get_datetime().isoformat()
|
|
107
|
+
info["battery_percent"] = device.battery().value
|
|
108
|
+
return info
|
|
109
|
+
|
|
110
|
+
if allow_write:
|
|
111
|
+
|
|
112
|
+
@mcp.tool(annotations=WRITES_DEVICE)
|
|
113
|
+
def set_device_clock(utc_time: str | None = None) -> dict[str, Any]:
|
|
114
|
+
"""Set the instrument clock. Without argument it is synchronised to this computer's time.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
utc_time: Optional ISO 8601 time, e.g. "2026-10-28 17:00:00" (interpreted as UTC).
|
|
118
|
+
"""
|
|
119
|
+
with _device_errors():
|
|
120
|
+
when = datetime.fromisoformat(utc_time) if utc_time else datetime.now(timezone.utc)
|
|
121
|
+
if when.tzinfo is None:
|
|
122
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
123
|
+
device.set_datetime(when)
|
|
124
|
+
return {"clock_utc": device.get_datetime().isoformat()}
|
|
125
|
+
|
|
126
|
+
@mcp.tool(annotations=WRITES_DEVICE)
|
|
127
|
+
def set_location_name(index: int, name: str) -> dict[str, Any]:
|
|
128
|
+
"""Rename one of the 20 location labels used when saving single readings on the instrument.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
index: Label number 1..20.
|
|
132
|
+
name: New label text, at most 16 ASCII characters.
|
|
133
|
+
"""
|
|
134
|
+
with _device_errors():
|
|
135
|
+
reply = device.update_location(index, name)
|
|
136
|
+
return {"index": index, "name": name, "reply": reply}
|
|
137
|
+
|
|
138
|
+
return mcp
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> None:
|
|
142
|
+
parser = argparse.ArgumentParser(prog="mcp-server-senseca-pro", description=__doc__)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--port",
|
|
145
|
+
default=os.environ.get("SENSECA_PORT"),
|
|
146
|
+
help="serial port, e.g. /dev/ttyACM0 or COM3 (env SENSECA_PORT; default: auto-detect)",
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument(
|
|
149
|
+
"--allow-write",
|
|
150
|
+
action="store_true",
|
|
151
|
+
default=os.environ.get("SENSECA_ALLOW_WRITE", "").lower() in ("1", "true", "yes"),
|
|
152
|
+
help="enable tools that change instrument settings (env SENSECA_ALLOW_WRITE)",
|
|
153
|
+
)
|
|
154
|
+
parser.add_argument("--log-level", default=os.environ.get("SENSECA_LOG_LEVEL", "WARNING"))
|
|
155
|
+
args = parser.parse_args()
|
|
156
|
+
|
|
157
|
+
# stdout carries the MCP protocol, so logs must go to stderr.
|
|
158
|
+
logging.basicConfig(stream=sys.stderr, level=args.log_level.upper(), format="%(levelname)s %(name)s: %(message)s")
|
|
159
|
+
|
|
160
|
+
# The PIN is only read from the environment so it never ends up in a process list or chat.
|
|
161
|
+
device = Pro11x(port=args.port, pin=os.environ.get("SENSECA_PIN"))
|
|
162
|
+
try:
|
|
163
|
+
create_server(device, allow_write=args.allow_write).run("stdio")
|
|
164
|
+
finally:
|
|
165
|
+
device.close()
|