keithley-tempcontrol 0.17.3__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.
@@ -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())