keithley-tempcontrol 0.17.4__py3-none-any.whl → 0.18.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- egse/tempcontrol/keithley/__init__.py +0 -2
- egse/tempcontrol/keithley/daq6510.py +79 -242
- egse/tempcontrol/keithley/daq6510_adev.py +13 -1
- egse/tempcontrol/keithley/daq6510_amon.py +569 -0
- egse/tempcontrol/keithley/daq6510_cs.py +27 -1
- egse/tempcontrol/keithley/daq6510_dev.py +23 -214
- egse/tempcontrol/keithley/daq6510_mon.py +242 -489
- egse/tempcontrol/keithley/daq6510_protocol.py +29 -7
- egse/tempcontrol/keithley/daq6510_sim.py +4 -4
- keithley_tempcontrol/cgse_services.py +32 -0
- {keithley_tempcontrol-0.17.4.dist-info → keithley_tempcontrol-0.18.1.dist-info}/METADATA +1 -1
- keithley_tempcontrol-0.18.1.dist-info/RECORD +18 -0
- {keithley_tempcontrol-0.17.4.dist-info → keithley_tempcontrol-0.18.1.dist-info}/entry_points.txt +1 -0
- keithley_tempcontrol-0.17.4.dist-info/RECORD +0 -17
- {keithley_tempcontrol-0.17.4.dist-info → keithley_tempcontrol-0.18.1.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import datetime
|
|
3
|
+
import json
|
|
4
|
+
import signal
|
|
5
|
+
import time
|
|
6
|
+
from asyncio import Task
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from typing import Callable
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
import zmq
|
|
14
|
+
import zmq.asyncio
|
|
15
|
+
|
|
16
|
+
from egse.device import DeviceConnectionError
|
|
17
|
+
from egse.device import DeviceTimeoutError
|
|
18
|
+
from egse.log import logger
|
|
19
|
+
from egse.settings import Settings
|
|
20
|
+
from egse.system import TyperAsyncCommand
|
|
21
|
+
from egse.tempcontrol.keithley.daq6510_adev import DAQ6510
|
|
22
|
+
|
|
23
|
+
settings = Settings.load("Keithley DAQ6510")
|
|
24
|
+
|
|
25
|
+
DAQ_DEV_HOST = settings.get("HOSTNAME", "localhost")
|
|
26
|
+
DAQ_DEV_PORT = settings.get("PORT", 5025)
|
|
27
|
+
|
|
28
|
+
DAQ_MON_CMD_PORT = 5556
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DAQ6510Monitor:
|
|
32
|
+
"""
|
|
33
|
+
DAQ6510 temperature monitoring service with ZeroMQ command interface.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
daq_hostname: str,
|
|
40
|
+
daq_port: int = DAQ_DEV_PORT,
|
|
41
|
+
zmq_port: int = DAQ_MON_CMD_PORT,
|
|
42
|
+
log_file: str = "temperature_readings.log",
|
|
43
|
+
channels: list[str] | None = None,
|
|
44
|
+
poll_interval: float = 60.0,
|
|
45
|
+
):
|
|
46
|
+
"""Initialize the DAQ6510 monitoring service.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
daq_hostname: Hostname or IP of the DAQ6510
|
|
50
|
+
daq_port: TCP port for DAQ6510 SCPI interface
|
|
51
|
+
zmq_port: Port for ZeroMQ command interface
|
|
52
|
+
log_file: Path to log file for temperature readings
|
|
53
|
+
channels: List of channels to monitor (e.g. ["101", "102"])
|
|
54
|
+
poll_interval: Initial polling interval in seconds
|
|
55
|
+
"""
|
|
56
|
+
self.daq_hostname = daq_hostname
|
|
57
|
+
self.daq_port = daq_port
|
|
58
|
+
self.zmq_port = zmq_port
|
|
59
|
+
self.log_file = Path(log_file)
|
|
60
|
+
self.channels = channels or ["101", "102", "103", "104"]
|
|
61
|
+
self.poll_interval = poll_interval
|
|
62
|
+
|
|
63
|
+
# Setup ZeroMQ context
|
|
64
|
+
self.ctx = zmq.asyncio.Context()
|
|
65
|
+
self.socket = self.ctx.socket(zmq.ROUTER)
|
|
66
|
+
self.socket.bind(f"tcp://*:{zmq_port}")
|
|
67
|
+
|
|
68
|
+
# Service state
|
|
69
|
+
self.running = False
|
|
70
|
+
self.polling_active = False
|
|
71
|
+
self.daq_interface = None
|
|
72
|
+
self.command_handlers: dict[str, Callable] = {
|
|
73
|
+
"START_POLLING": self._handle_start_polling,
|
|
74
|
+
"STOP_POLLING": self._handle_stop_polling,
|
|
75
|
+
"SET_INTERVAL": self._handle_set_interval,
|
|
76
|
+
"SET_CHANNELS": self._handle_set_channels,
|
|
77
|
+
"GET_STATUS": self._handle_get_status,
|
|
78
|
+
"GET_READING": self._handle_get_reading,
|
|
79
|
+
"GET_LAST_READING": self._handle_get_last_reading,
|
|
80
|
+
"SHUTDOWN": self._handle_shutdown,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Keep a record of the last measurement
|
|
84
|
+
self._last_reading: dict = {}
|
|
85
|
+
|
|
86
|
+
# Make sure the log directory exists
|
|
87
|
+
self.log_file.parent.mkdir(exist_ok=True, parents=True)
|
|
88
|
+
|
|
89
|
+
# Create DAQ interface
|
|
90
|
+
# In this case we use the device itself, no control server. That means
|
|
91
|
+
# the monitoring must be the only service connecting to the device.
|
|
92
|
+
self.daq_interface = DAQ6510(hostname=daq_hostname, port=daq_port)
|
|
93
|
+
|
|
94
|
+
async def start(self):
|
|
95
|
+
"""Start the monitoring service."""
|
|
96
|
+
logger.info(f"Starting DAQ6510 Monitoring Service on ZMQ port {self.zmq_port}")
|
|
97
|
+
self.running = True
|
|
98
|
+
|
|
99
|
+
def handle_shutdown():
|
|
100
|
+
asyncio.create_task(self.shutdown())
|
|
101
|
+
|
|
102
|
+
# Register signal handlers for graceful shutdown
|
|
103
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
104
|
+
asyncio.get_event_loop().add_signal_handler(sig, handle_shutdown)
|
|
105
|
+
|
|
106
|
+
# Start the main service tasks
|
|
107
|
+
await asyncio.gather(self.command_listener(), self.connect_daq(), return_exceptions=True)
|
|
108
|
+
|
|
109
|
+
def done_polling(self, task: Task):
|
|
110
|
+
if task.exception():
|
|
111
|
+
logger.error(f"Polling loop ended unexpectedly: {task.exception()}")
|
|
112
|
+
logger.info(f"Done polling ({task.get_name()}).")
|
|
113
|
+
self.polling_active = False
|
|
114
|
+
|
|
115
|
+
async def connect_daq(self):
|
|
116
|
+
"""Establish connection to the DAQ6510."""
|
|
117
|
+
while self.running:
|
|
118
|
+
assert self.daq_interface is not None # extra check and make mypy happy
|
|
119
|
+
|
|
120
|
+
init_commands = [
|
|
121
|
+
('TRAC:MAKE "test1", 1000', False), # create a new buffer
|
|
122
|
+
# settings for channel 1 and 2 of slot 1
|
|
123
|
+
('SENS:FUNC "TEMP", (@101:102)', False), # set the function to temperature
|
|
124
|
+
("SENS:TEMP:TRAN FRTD, (@101)", False), # set the transducer to 4-wire RTD
|
|
125
|
+
("SENS:TEMP:RTD:FOUR PT100, (@101)", False), # set the type of the 4-wire RTD
|
|
126
|
+
("SENS:TEMP:TRAN RTD, (@102)", False), # set the transducer to 2-wire RTD
|
|
127
|
+
("SENS:TEMP:RTD:TWO PT100, (@102)", False), # set the type of the 2-wire RTD
|
|
128
|
+
('ROUT:SCAN:BUFF "test1"', False),
|
|
129
|
+
("ROUT:SCAN:CRE (@101:102)", False),
|
|
130
|
+
("ROUT:CHAN:OPEN (@101:102)", False),
|
|
131
|
+
("ROUT:STAT? (@101:102)", True),
|
|
132
|
+
("ROUT:SCAN:STAR:STIM NONE", False),
|
|
133
|
+
# ("ROUT:SCAN:ADD:SING (@101, 102)", False), # not sure what this does, not really needed
|
|
134
|
+
("ROUT:SCAN:COUN:SCAN 1", False), # not sure if this is needed in this setting
|
|
135
|
+
# ("ROUT:SCAN:INT 1", False),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
logger.info(f"Connecting to DAQ6510 at {self.daq_hostname}:{self.daq_port}")
|
|
140
|
+
await self.daq_interface.connect()
|
|
141
|
+
logger.info("Successfully connected to DAQ6510.")
|
|
142
|
+
await self.daq_interface.initialize(commands=init_commands, reset_device=True)
|
|
143
|
+
logger.info("Successfully initialized DAQ6510 for measurements.")
|
|
144
|
+
|
|
145
|
+
# If we were polling before, restart it.
|
|
146
|
+
# The first time we enter this loop, we are not polling.
|
|
147
|
+
if self.polling_active:
|
|
148
|
+
# QUESTION: Do we need to await here?
|
|
149
|
+
polling_task = asyncio.create_task(self.polling_loop())
|
|
150
|
+
|
|
151
|
+
# But we can add error handling for the task
|
|
152
|
+
polling_task.add_done_callback(self.done_polling)
|
|
153
|
+
|
|
154
|
+
# Keep checking connection status periodically
|
|
155
|
+
while self.running and await self.daq_interface.is_connected():
|
|
156
|
+
logger.info("Checking DAQ6510 connection...")
|
|
157
|
+
await asyncio.sleep(10)
|
|
158
|
+
|
|
159
|
+
if self.running:
|
|
160
|
+
logger.warning("Lost connection to DAQ6510")
|
|
161
|
+
await self.daq_interface.disconnect()
|
|
162
|
+
|
|
163
|
+
except (DeviceConnectionError, DeviceTimeoutError) as exc:
|
|
164
|
+
logger.error(f"Failed to connect to DAQ6510: {exc}")
|
|
165
|
+
await asyncio.sleep(5) # Wait before retrying
|
|
166
|
+
|
|
167
|
+
async def polling_loop(self):
|
|
168
|
+
"""Main polling loop for temperature measurements."""
|
|
169
|
+
logger.info(f"Starting temperature polling loop (interval: {self.poll_interval}s, channels: {self.channels})")
|
|
170
|
+
|
|
171
|
+
assert self.daq_interface is not None # extra check and make mypy happy
|
|
172
|
+
|
|
173
|
+
# The next lines are a way to calculate the sleep time between two measurements, this takes the time of the
|
|
174
|
+
# measurement itself into account.
|
|
175
|
+
def interval():
|
|
176
|
+
next_time = time.perf_counter()
|
|
177
|
+
while True:
|
|
178
|
+
next_time += self.poll_interval
|
|
179
|
+
yield max(next_time - time.perf_counter(), 0)
|
|
180
|
+
|
|
181
|
+
g_interval = interval()
|
|
182
|
+
|
|
183
|
+
while self.running and self.polling_active:
|
|
184
|
+
try:
|
|
185
|
+
if not await self.daq_interface.is_connected():
|
|
186
|
+
logger.warning("DAQ6510 not connected, skipping temperature reading")
|
|
187
|
+
await asyncio.sleep(5)
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
timestamp = datetime.datetime.now().isoformat()
|
|
191
|
+
readings = {}
|
|
192
|
+
|
|
193
|
+
# Read temperature from each channel
|
|
194
|
+
for channel in self.channels:
|
|
195
|
+
try:
|
|
196
|
+
# temp = random.random()
|
|
197
|
+
temp = await self.daq_interface.get_measurement(channel)
|
|
198
|
+
readings[channel] = temp
|
|
199
|
+
except (DeviceConnectionError, DeviceTimeoutError, ValueError) as exc:
|
|
200
|
+
logger.error(f"Error reading channel {channel}: {exc}")
|
|
201
|
+
readings[channel] = None
|
|
202
|
+
|
|
203
|
+
# Log the readings
|
|
204
|
+
log_entry = {"timestamp": timestamp, "readings": readings}
|
|
205
|
+
|
|
206
|
+
# Append to log file
|
|
207
|
+
with open(self.log_file, "a") as fd:
|
|
208
|
+
fd.write(json.dumps(log_entry) + "\n")
|
|
209
|
+
|
|
210
|
+
self._last_reading.update({"timestamp": timestamp, "readings": readings})
|
|
211
|
+
|
|
212
|
+
logger.info(f"Temperature readings: {readings}")
|
|
213
|
+
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
logger.exception(f"Error in polling loop: {exc}")
|
|
216
|
+
|
|
217
|
+
finally:
|
|
218
|
+
# Wait for next polling interval, we account for the time needed to perform the measurement.
|
|
219
|
+
await asyncio.sleep(next(g_interval))
|
|
220
|
+
|
|
221
|
+
logger.info("Temperature polling loop stopped")
|
|
222
|
+
|
|
223
|
+
async def command_listener(self):
|
|
224
|
+
"""ZeroMQ command interface listener."""
|
|
225
|
+
logger.info("Command listener started")
|
|
226
|
+
|
|
227
|
+
while self.running:
|
|
228
|
+
try:
|
|
229
|
+
# Wait for next message
|
|
230
|
+
message = await self.socket.recv_multipart()
|
|
231
|
+
|
|
232
|
+
# Parse the message
|
|
233
|
+
if len(message) < 3:
|
|
234
|
+
logger.warning(f"Received malformed message: {message}")
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
identity, empty, *payload = message
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
# Parse the command and parameters
|
|
241
|
+
command_data = json.loads(payload[0].decode("utf-8"))
|
|
242
|
+
command = command_data.get("command")
|
|
243
|
+
params = command_data.get("params", {})
|
|
244
|
+
|
|
245
|
+
logger.info(f"Received command: {command} from {identity}")
|
|
246
|
+
|
|
247
|
+
# Handle the command
|
|
248
|
+
if command in self.command_handlers:
|
|
249
|
+
response = await self.command_handlers[command](params)
|
|
250
|
+
else:
|
|
251
|
+
response = {"status": "error", "message": f"Unknown command: {command}"}
|
|
252
|
+
|
|
253
|
+
except json.JSONDecodeError:
|
|
254
|
+
response = {"status": "error", "message": "Invalid JSON format"}
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
logger.exception(f"Error processing command: {exc}")
|
|
257
|
+
response = {"status": "error", "message": str(exc)}
|
|
258
|
+
|
|
259
|
+
# Send response
|
|
260
|
+
await self.socket.send_multipart([identity, b"", json.dumps(response).encode("utf-8")])
|
|
261
|
+
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
logger.exception(f"Error in command listener: {exc}")
|
|
264
|
+
await asyncio.sleep(1)
|
|
265
|
+
|
|
266
|
+
async def _handle_start_polling(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
267
|
+
"""Start temperature polling."""
|
|
268
|
+
if not self.polling_active:
|
|
269
|
+
self.polling_active = True
|
|
270
|
+
|
|
271
|
+
# If channels provided, update them
|
|
272
|
+
if "channels" in params:
|
|
273
|
+
self.channels = params["channels"]
|
|
274
|
+
|
|
275
|
+
# If interval provided, update it
|
|
276
|
+
if "interval" in params:
|
|
277
|
+
self.poll_interval = float(params["interval"])
|
|
278
|
+
|
|
279
|
+
# Start polling loop
|
|
280
|
+
polling_task = asyncio.create_task(self.polling_loop())
|
|
281
|
+
|
|
282
|
+
# But we can add error handling for the task
|
|
283
|
+
polling_task.add_done_callback(
|
|
284
|
+
lambda t: logger.error(f"Polling loop ended unexpectedly: {t.exception()}") if t.exception() else None
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
"status": "ok",
|
|
289
|
+
"message": f"Polling started with interval {self.poll_interval}s and channels {self.channels}",
|
|
290
|
+
}
|
|
291
|
+
else:
|
|
292
|
+
return {"status": "ok", "message": "Polling already active"}
|
|
293
|
+
|
|
294
|
+
async def _handle_stop_polling(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
295
|
+
"""Stop temperature polling."""
|
|
296
|
+
if self.polling_active:
|
|
297
|
+
self.polling_active = False
|
|
298
|
+
return {"status": "ok", "message": "Polling stopped"}
|
|
299
|
+
else:
|
|
300
|
+
return {"status": "ok", "message": "Polling already stopped"}
|
|
301
|
+
|
|
302
|
+
async def _handle_set_interval(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
303
|
+
"""Set polling interval."""
|
|
304
|
+
if "interval" not in params:
|
|
305
|
+
return {"status": "error", "message": "Missing required parameter: interval"}
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
interval = float(params["interval"])
|
|
309
|
+
if interval <= 0:
|
|
310
|
+
return {"status": "error", "message": "Interval must be positive"}
|
|
311
|
+
|
|
312
|
+
old_interval = self.poll_interval
|
|
313
|
+
self.poll_interval = interval
|
|
314
|
+
|
|
315
|
+
return {"status": "ok", "message": f"Polling interval changed from {old_interval}s to {interval}s"}
|
|
316
|
+
except ValueError:
|
|
317
|
+
return {"status": "error", "message": "Invalid interval format"}
|
|
318
|
+
|
|
319
|
+
async def _handle_set_channels(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
320
|
+
"""Set channels to monitor."""
|
|
321
|
+
if "channels" not in params or not isinstance(params["channels"], list):
|
|
322
|
+
return {"status": "error", "message": "Missing or invalid parameter: channels (should be a list)"}
|
|
323
|
+
|
|
324
|
+
old_channels = self.channels.copy()
|
|
325
|
+
self.channels = params["channels"]
|
|
326
|
+
|
|
327
|
+
return {"status": "ok", "message": f"Monitoring channels changed from {old_channels} to {self.channels}"}
|
|
328
|
+
|
|
329
|
+
async def _handle_get_last_reading(self, params: dict[str, Any]):
|
|
330
|
+
return self._last_reading
|
|
331
|
+
|
|
332
|
+
async def _handle_get_reading(self, params: dict[str, Any]):
|
|
333
|
+
"""Get a reading for the given channel(s)."""
|
|
334
|
+
logger.info(f"GET_READING – {params = }")
|
|
335
|
+
|
|
336
|
+
assert self.daq_interface is not None # extra check and make mypy happy
|
|
337
|
+
|
|
338
|
+
readings = {"status": "ok", "data": {}}
|
|
339
|
+
|
|
340
|
+
for channel in params["channels"]:
|
|
341
|
+
try:
|
|
342
|
+
temp = await self.daq_interface.get_measurement(channel)
|
|
343
|
+
readings["data"][channel] = temp
|
|
344
|
+
except (DeviceConnectionError, DeviceTimeoutError, ValueError, RuntimeError) as exc:
|
|
345
|
+
logger.error(f"Error reading channel {channel}: {exc}")
|
|
346
|
+
readings["data"][channel] = None
|
|
347
|
+
readings.update({"status": "error", "message": f"Error reading channel {channel}"})
|
|
348
|
+
|
|
349
|
+
return readings
|
|
350
|
+
|
|
351
|
+
async def _handle_get_status(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
352
|
+
"""Get current service status."""
|
|
353
|
+
connected = False
|
|
354
|
+
try:
|
|
355
|
+
if self.daq_interface:
|
|
356
|
+
connected = await self.daq_interface.is_connected()
|
|
357
|
+
except Exception:
|
|
358
|
+
connected = False
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
"status": "ok",
|
|
362
|
+
"data": {
|
|
363
|
+
"service_running": self.running,
|
|
364
|
+
"polling_active": self.polling_active,
|
|
365
|
+
"poll_interval": self.poll_interval,
|
|
366
|
+
"channels": self.channels,
|
|
367
|
+
"daq_connected": connected,
|
|
368
|
+
"daq_hostname": self.daq_hostname,
|
|
369
|
+
"daq_port": self.daq_port,
|
|
370
|
+
},
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async def _handle_shutdown(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
374
|
+
"""Shutdown the service."""
|
|
375
|
+
# Schedule shutdown after sending response
|
|
376
|
+
_ = asyncio.create_task(self.shutdown())
|
|
377
|
+
|
|
378
|
+
return {"status": "ok", "message": "Service shutting down"}
|
|
379
|
+
|
|
380
|
+
async def shutdown(self):
|
|
381
|
+
"""Gracefully shut down the service."""
|
|
382
|
+
logger.info("Shutting down DAQ Monitoring Service...")
|
|
383
|
+
|
|
384
|
+
# Stop the main loops
|
|
385
|
+
self.running = False
|
|
386
|
+
self.polling_active = False
|
|
387
|
+
|
|
388
|
+
# Disconnect DAQ
|
|
389
|
+
try:
|
|
390
|
+
logger.info("Disconnecting the DAQ6510...")
|
|
391
|
+
if self.daq_interface and await self.daq_interface.is_connected():
|
|
392
|
+
await self.daq_interface.disconnect()
|
|
393
|
+
except Exception as exc:
|
|
394
|
+
logger.error(f"Error disconnecting from DAQ: {exc}")
|
|
395
|
+
|
|
396
|
+
# Close ZeroMQ socket
|
|
397
|
+
try:
|
|
398
|
+
logger.info("Closing ZeroMQ socket and terminate context...")
|
|
399
|
+
self.socket.close()
|
|
400
|
+
self.ctx.term()
|
|
401
|
+
except Exception as exc:
|
|
402
|
+
logger.error(f"Error closing ZeroMQ socket: {exc}")
|
|
403
|
+
|
|
404
|
+
logger.info("Service shutdown complete")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class DAQMonitorClient:
|
|
408
|
+
"""A simple client for interacting with the DAQ Monitor Service."""
|
|
409
|
+
|
|
410
|
+
def __init__(self, server_address: str = "localhost", port: int = DAQ_MON_CMD_PORT, timeout: float = 5.0):
|
|
411
|
+
"""Initialize the client.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
server_address: Address of the monitoring service
|
|
415
|
+
port: ZeroMQ port
|
|
416
|
+
timeout: Command timeout in seconds
|
|
417
|
+
"""
|
|
418
|
+
self.server_address = server_address
|
|
419
|
+
self.port = port
|
|
420
|
+
self.timeout = timeout
|
|
421
|
+
|
|
422
|
+
self.ctx = zmq.Context().instance()
|
|
423
|
+
self.socket = None
|
|
424
|
+
|
|
425
|
+
def connect(self):
|
|
426
|
+
"""Connect to the DAQ Monitoring service."""
|
|
427
|
+
self.socket = self.ctx.socket(zmq.DEALER)
|
|
428
|
+
self.socket.connect(f"tcp://{self.server_address}:{self.port}")
|
|
429
|
+
self.socket.setsockopt(zmq.RCVTIMEO, int(self.timeout * 1000))
|
|
430
|
+
|
|
431
|
+
def disconnect(self):
|
|
432
|
+
"""Close the client connection."""
|
|
433
|
+
if self.socket:
|
|
434
|
+
self.socket.close(linger=100)
|
|
435
|
+
self.ctx.term()
|
|
436
|
+
|
|
437
|
+
def __enter__(self):
|
|
438
|
+
self.connect()
|
|
439
|
+
return self
|
|
440
|
+
|
|
441
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
442
|
+
self.disconnect()
|
|
443
|
+
if exc_type:
|
|
444
|
+
logger.error(f"Caught {exc_type}: {exc_val}")
|
|
445
|
+
|
|
446
|
+
def _send_command(self, command: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
447
|
+
"""Send a command to the monitoring service.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
command: Command name
|
|
451
|
+
params: Optional command parameters
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
Response from the service as a dictionary.
|
|
455
|
+
"""
|
|
456
|
+
params = params or {}
|
|
457
|
+
message = {"command": command, "params": params}
|
|
458
|
+
|
|
459
|
+
assert self.socket is not None, "Client not connected. Call connect() first."
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
self.socket.send_multipart([b"", json.dumps(message).encode("utf-8")])
|
|
463
|
+
_, response_data = self.socket.recv_multipart()
|
|
464
|
+
return json.loads(response_data.decode("utf-8"))
|
|
465
|
+
except zmq.ZMQError as exc:
|
|
466
|
+
return {"status": "error", "message": f"ZMQ error: {exc}"}
|
|
467
|
+
except Exception as exc:
|
|
468
|
+
return {"status": "error", "message": f"Error: {exc}"}
|
|
469
|
+
|
|
470
|
+
def start_polling(self, channels: Optional[list[str]] = None, interval: Optional[float] = None) -> dict[str, Any]:
|
|
471
|
+
"""Start polling on specified channels.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
channels: List of channels to monitor
|
|
475
|
+
interval: Polling interval in seconds
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
Response from the service
|
|
479
|
+
"""
|
|
480
|
+
params = {}
|
|
481
|
+
if channels is not None:
|
|
482
|
+
params["channels"] = channels
|
|
483
|
+
if interval is not None:
|
|
484
|
+
params["interval"] = interval
|
|
485
|
+
|
|
486
|
+
return self._send_command("START_POLLING", params)
|
|
487
|
+
|
|
488
|
+
def stop_polling(self) -> dict[str, Any]:
|
|
489
|
+
"""Stop polling.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Response from the service
|
|
493
|
+
"""
|
|
494
|
+
return self._send_command("STOP_POLLING")
|
|
495
|
+
|
|
496
|
+
def set_interval(self, interval: float) -> dict[str, Any]:
|
|
497
|
+
"""Set polling interval.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
interval: New polling interval in seconds
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
Response from the service
|
|
504
|
+
"""
|
|
505
|
+
return self._send_command("SET_INTERVAL", {"interval": interval})
|
|
506
|
+
|
|
507
|
+
def set_channels(self, channels: list[str]) -> dict[str, Any]:
|
|
508
|
+
"""Set channels to monitor.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
channels: List of channel identifiers
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
Response from the service
|
|
515
|
+
"""
|
|
516
|
+
return self._send_command("SET_CHANNELS", {"channels": channels})
|
|
517
|
+
|
|
518
|
+
def get_reading(self, channels: list[str]) -> dict[str, float]:
|
|
519
|
+
"""Get a reading from the given channel.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
A dictionary with the value of the measurement for the given channel.
|
|
523
|
+
"""
|
|
524
|
+
return self._send_command("GET_READING", {"channels": channels})
|
|
525
|
+
|
|
526
|
+
def get_last_reading(self) -> dict:
|
|
527
|
+
return self._send_command("GET_LAST_READING")
|
|
528
|
+
|
|
529
|
+
def get_status(self) -> dict[str, Any]:
|
|
530
|
+
"""Get current service status.
|
|
531
|
+
|
|
532
|
+
To confirm the status is 'ok', check the response for the key 'status'.
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
Status information as dictionary.
|
|
536
|
+
"""
|
|
537
|
+
return self._send_command("GET_STATUS")
|
|
538
|
+
|
|
539
|
+
def shutdown(self) -> dict[str, Any]:
|
|
540
|
+
"""Shutdown the service.
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Response from the service
|
|
544
|
+
"""
|
|
545
|
+
return self._send_command("SHUTDOWN")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
app = typer.Typer(name="daq6510_mon")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@app.command(cls=TyperAsyncCommand, name="monitor")
|
|
552
|
+
async def main(log_file: str = "temperature_readings.log"):
|
|
553
|
+
"""
|
|
554
|
+
Start the DAQ6510 monitoring app in the background.
|
|
555
|
+
"""
|
|
556
|
+
monitor = DAQ6510Monitor(
|
|
557
|
+
daq_hostname=DAQ_DEV_HOST,
|
|
558
|
+
daq_port=DAQ_DEV_PORT,
|
|
559
|
+
zmq_port=DAQ_MON_CMD_PORT,
|
|
560
|
+
log_file=log_file,
|
|
561
|
+
channels=["101", "102"],
|
|
562
|
+
poll_interval=10.0,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
await monitor.start()
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
if __name__ == "__main__":
|
|
569
|
+
asyncio.run(app())
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Keithley DAQ6510 Synchronous Control Server.
|
|
3
|
+
|
|
4
|
+
This module implements a Control Server to command and monitor a Keithley DAQ6510 Data Acquisition System.
|
|
5
|
+
|
|
6
|
+
The control server can be started from the command line interface as follows:
|
|
7
|
+
|
|
8
|
+
$ daq6510_cs start
|
|
9
|
+
|
|
10
|
+
It can also be stopped, queried for status information, etc. Use the --help option to see all available commands:
|
|
11
|
+
|
|
12
|
+
$ daq6510_cs --help
|
|
13
|
+
|
|
14
|
+
Functions:
|
|
15
|
+
|
|
16
|
+
is_daq6510_cs_active(timeout: float = 0.5) -> bool
|
|
17
|
+
Checks if the DAQ6510 Control Server is running.
|
|
18
|
+
Classes:
|
|
19
|
+
DAQ6510ControlServer(ControlServer)
|
|
20
|
+
Keithley DAQ6510ControlServer - Command and monitor the Keithley Data Acquisition System.
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
|
|
1
24
|
import multiprocessing
|
|
2
25
|
import sys
|
|
3
26
|
|
|
@@ -64,7 +87,7 @@ class DAQ6510ControlServer(ControlServer):
|
|
|
64
87
|
"""
|
|
65
88
|
|
|
66
89
|
def __init__(self):
|
|
67
|
-
"""
|
|
90
|
+
"""Initialization of a DAQ6510 Control Server."""
|
|
68
91
|
|
|
69
92
|
super().__init__()
|
|
70
93
|
|
|
@@ -154,6 +177,9 @@ class DAQ6510ControlServer(ControlServer):
|
|
|
154
177
|
def before_serve(self):
|
|
155
178
|
"""Steps to take before the Control Server is activated."""
|
|
156
179
|
|
|
180
|
+
def after_serve(self):
|
|
181
|
+
self.deregister_service()
|
|
182
|
+
|
|
157
183
|
|
|
158
184
|
app = typer.Typer(name="daq6510_cs")
|
|
159
185
|
|