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.
@@ -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,5 @@
1
+ """MCP server for Senseca PRO 111 / PRO 115 Pt100 thermometers."""
2
+
3
+ from .server import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -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()