cgse-core 0.17.2__py3-none-any.whl → 0.17.3__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/async_control.py DELETED
@@ -1,1085 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import datetime
5
- import json
6
- import pickle
7
- import uuid
8
- from asyncio import Event
9
- from asyncio import Task
10
- from enum import Enum
11
- from enum import auto
12
- from typing import Any
13
- from typing import Callable
14
-
15
- import zmq.asyncio
16
- from rich.traceback import Traceback
17
-
18
- from egse.env import bool_env
19
- from egse.exceptions import InitializationError
20
- from egse.log import logging
21
- from egse.registry.client import AsyncRegistryClient
22
- from egse.system import Periodic
23
- from egse.system import get_current_location
24
- from egse.system import get_host_ip
25
- from egse.system import log_rich_output
26
- from egse.system import type_name
27
- from egse.zmq_ser import get_port_number
28
- from egse.zmq_ser import set_address_port
29
- from egse.zmq_ser import zmq_error_response
30
- from egse.zmq_ser import zmq_json_request
31
- from egse.zmq_ser import zmq_json_response
32
- from egse.zmq_ser import zmq_string_request
33
- from egse.zmq_ser import zmq_string_response
34
-
35
- logger = logging.getLogger("egse.async_control")
36
-
37
- # When zero (0) ports will be dynamically allocated by the system
38
- CONTROL_SERVER_DEVICE_COMMANDING_PORT = 0
39
- CONTROL_SERVER_SERVICE_COMMANDING_PORT = 0
40
-
41
- CONTROL_SERVER_SERVICE_TYPE = "async-control-server"
42
- CONTROL_CLIENT_ID = "async-control-client"
43
-
44
- VERBOSE_DEBUG: bool = bool_env("VERBOSE_DEBUG")
45
-
46
- RECREATE_SOCKET = False
47
- """Recreate ZeroMQ socket after a timeout. Set to True when experiencing problems
48
- with corrupted socket states. This is mainly used for REQ-REP protocols."""
49
-
50
-
51
- class SocketType(Enum):
52
- """The socket type defines which socket to use for the intended communication."""
53
-
54
- DEVICE = auto()
55
- SERVICE = auto()
56
-
57
-
58
- async def is_control_server_active(service_type: str, timeout: float = 0.5) -> bool:
59
- """
60
- Checks if the Control Server is running.
61
-
62
- This function sends a *Ping* message to the Control Server and expects a *Pong* answer back within the timeout
63
- period.
64
-
65
- Args:
66
- service_type (str): the service type of the control server to check
67
- timeout (float): Timeout when waiting for a reply [s, default=0.5]
68
-
69
- Returns:
70
- True if the Control Server is running and replied with the expected answer; False otherwise.
71
- """
72
-
73
- # I have a choice here to check if the control server is active/healthy.
74
- #
75
- # 1. I can connect to the ServiceRegistry, and analyse the 'health' field if it is 'passing', or
76
-
77
- with AsyncRegistryClient() as registry:
78
- service = await registry.discover_service(service_type)
79
- if service:
80
- return True if service["health"] == "passing" else False
81
- else:
82
- return False
83
-
84
- # 2. I can connect to the control server (by first contacting the service registry) and send a ping.
85
-
86
- # async with AsyncControlClient(service_type=service_type) as client:
87
- # response = await client.ping()
88
- # return response == 'pong'
89
-
90
-
91
- class AsyncControlServer:
92
- def __init__(self):
93
- self.interrupted: Event = asyncio.Event()
94
- self.logger = logging.getLogger("egse.async_control.server")
95
-
96
- self.mon_delay = 1000
97
- """Delay between publish status information [ms]."""
98
- self.hk_delay = 1000
99
- """Delay between saving housekeeping information [ms]."""
100
-
101
- # self._process_status = ProcessStatus()
102
-
103
- self._service_id = None
104
-
105
- self._sequential_queue = asyncio.Queue()
106
- """Queue for sequential operations that must preserve order of execution."""
107
-
108
- self.device_command_port = CONTROL_SERVER_DEVICE_COMMANDING_PORT
109
- """The device commanding port for the control server. This will be 0 at start and dynamically assigned by the
110
- system."""
111
-
112
- self.service_command_port = CONTROL_SERVER_SERVICE_COMMANDING_PORT
113
- """The service commanding port for the control server. This will be 0 at start and dynamically assigned by the
114
- system."""
115
-
116
- self.device_command_handlers: dict[str, Callable] = {}
117
- """Dictionary mapping device command names to their handler functions."""
118
-
119
- self.service_command_handlers: dict[str, Callable] = {}
120
- """Dictionary mapping service command names to their handler functions."""
121
-
122
- self._tasks: list[Task] = []
123
- """The background top-level tasks that are performed by the control server."""
124
-
125
- self._ctx = zmq.asyncio.Context.instance()
126
-
127
- # Socket to handle REQ-REP device commanding pattern
128
- self.device_command_socket: zmq.asyncio.Socket = self._ctx.socket(zmq.ROUTER)
129
-
130
- # Socket to handle REQ-REP service commanding pattern
131
- self.service_command_socket: zmq.asyncio.Socket = self._ctx.socket(zmq.ROUTER)
132
-
133
- # Add some default device command handlers
134
- self.add_device_command_handler("block", self._do_block)
135
- self.add_device_command_handler("say", self._do_say)
136
-
137
- # Add some default service command handlers
138
- self.add_service_command_handler("terminate", self._handle_terminate)
139
- self.add_service_command_handler("info", self._handle_info)
140
- self.add_service_command_handler("ping", self._handle_ping)
141
- self.add_service_command_handler("block", self._handle_block)
142
-
143
- self.registry = AsyncRegistryClient()
144
-
145
- @staticmethod
146
- def get_ip_address() -> str:
147
- """Returns the IP address of the current host."""
148
- return get_host_ip() or "localhost"
149
-
150
- def connect_device_command_socket(self):
151
- self.device_command_socket.bind(f"tcp://*:{self.device_command_port}")
152
- self.device_command_port = get_port_number(self.device_command_socket)
153
-
154
- def connect_service_command_socket(self):
155
- self.service_command_socket.bind(f"tcp://*:{self.service_command_port}")
156
- self.service_command_port = get_port_number(self.service_command_socket)
157
-
158
- def stop(self):
159
- self.logger.warning("Stopping the async control server.")
160
- self.interrupted.set()
161
-
162
- async def start(self):
163
- self.connect_device_command_socket()
164
- self.connect_service_command_socket()
165
-
166
- await self.register_service()
167
-
168
- self._tasks = [
169
- asyncio.create_task(self.process_device_command(), name="process-device-commands"),
170
- asyncio.create_task(self.process_service_command(), name="process-service-commands"),
171
- asyncio.create_task(self.send_status_updates(), name="send-status-updates"),
172
- asyncio.create_task(self.process_sequential_queue(), name="process-sequential-queue"),
173
- ]
174
-
175
- try:
176
- while not self.interrupted.is_set():
177
- await self._check_tasks_health()
178
- await asyncio.sleep(1.0)
179
- except asyncio.CancelledError:
180
- self.logger.debug(f"Caught CancelledError on server keep-alive loop, terminating {type(self).__name__}.")
181
- finally:
182
- await self._cleanup_running_tasks()
183
-
184
- await self.deregister_service()
185
-
186
- self.disconnect_device_command_socket()
187
- self.disconnect_service_command_socket()
188
-
189
- async def register_service(self):
190
- self.logger.info("Registering service AsyncControlServer as type async-control-server")
191
-
192
- self.registry.connect()
193
-
194
- self._service_id = await self.registry.register(
195
- name=type_name(self),
196
- host=get_host_ip() or "127.0.0.1",
197
- port=get_port_number(self.device_command_socket),
198
- service_type=CONTROL_SERVER_SERVICE_TYPE,
199
- metadata={"service_port": get_port_number(self.service_command_socket)},
200
- )
201
- await self.registry.start_heartbeat()
202
-
203
- async def deregister_service(self):
204
- await self.registry.stop_heartbeat()
205
- await self.registry.deregister()
206
-
207
- self.registry.disconnect()
208
-
209
- async def _check_tasks_health(self):
210
- """Check if any tasks unexpectedly terminated."""
211
- for task in self._tasks:
212
- if task.done() and not task.cancelled():
213
- try:
214
- # This will raise any exception that occurred in the task
215
- task.result()
216
- except Exception as exc:
217
- self.logger.error(f"Task {task.get_name()} failed: {exc}", exc_info=True)
218
- # Potentially restart the task or shut down service
219
-
220
- async def _cleanup_running_tasks(self):
221
- # Cancel all running tasks
222
- for task in self._tasks:
223
- if not task.done():
224
- self.logger.debug(f"Cancelling task {task.get_name()}.")
225
- task.cancel()
226
-
227
- # Wait for tasks to complete their cancellation
228
- if self._tasks:
229
- try:
230
- await asyncio.gather(*self._tasks, return_exceptions=True)
231
- except asyncio.CancelledError as exc:
232
- self.logger.debug(f"Caught {type_name(exc)}: {exc}.")
233
- pass
234
-
235
- def disconnect_device_command_socket(self):
236
- self.logger.debug("Cleaning up device command sockets.")
237
- if self.device_command_socket:
238
- self.device_command_socket.close(linger=100)
239
-
240
- def disconnect_service_command_socket(self):
241
- self.logger.debug("Cleaning up service command sockets.")
242
- if self.service_command_socket:
243
- self.service_command_socket.close(linger=100)
244
-
245
- async def process_device_command(self):
246
- self.logger.info("Starting device command processing ...")
247
-
248
- while not self.interrupted.is_set():
249
- try:
250
- # Wait for a request with timeout to allow checking if still running
251
- try:
252
- parts = await asyncio.wait_for(self.device_command_socket.recv_multipart(), timeout=1.0)
253
- except asyncio.TimeoutError:
254
- continue
255
-
256
- if VERBOSE_DEBUG:
257
- self.logger.debug(f"Received multipart message: {parts}")
258
-
259
- # For commanding, we only accept simple commands as a string or a complex command with arguments as
260
- # JSON data. In both cases, there are only three parts in this multipart message.
261
- client_id, sequence_id, message_type, data = parts
262
- if message_type == b"MESSAGE_TYPE:STRING":
263
- device_command = {"command": data.decode("utf-8")}
264
- elif message_type == b"MESSAGE_TYPE:JSON":
265
- device_command = json.loads(data.decode())
266
- else:
267
- filename, lineno, function_name = get_current_location()
268
- # We have an unknown message format, send an error message back
269
- message = zmq_error_response(
270
- {
271
- "success": False,
272
- "message": f"Incorrect message type: {message_type}",
273
- "metadata": {
274
- "data": data.decode(),
275
- "file": filename,
276
- "lineno": lineno,
277
- "function": function_name,
278
- },
279
- }
280
- )
281
- await self.device_command_socket.send_multipart([client_id, sequence_id, *message])
282
- continue
283
-
284
- self.logger.debug(f"Received device command: {device_command}")
285
-
286
- self.logger.debug("Process the command...")
287
- response = await self._process_device_command(device_command)
288
-
289
- self.logger.debug("Send the response...")
290
- await self.device_command_socket.send_multipart([client_id, sequence_id, *response])
291
-
292
- except asyncio.CancelledError:
293
- self.logger.debug("Device command handling task cancelled.")
294
- break
295
-
296
- async def _process_device_command(self, cmd: dict[str, Any]) -> list:
297
- command = cmd.get("command")
298
- if not command:
299
- return zmq_error_response(
300
- {
301
- "success": False,
302
- "message": "no command field provide, don't know what to do.",
303
- }
304
- )
305
-
306
- handler = self.device_command_handlers.get(command)
307
- if not handler:
308
- filename, lineno, function_name = get_current_location()
309
- return zmq_error_response(
310
- {
311
- "success": False,
312
- "message": f"Unknown command: {command}",
313
- "metadata": {"file": filename, "lineno": lineno, "function": function_name},
314
- }
315
- )
316
-
317
- return await handler(cmd)
318
-
319
- def add_device_command_handler(self, command_name: str, command_handler: Callable):
320
- self.device_command_handlers[command_name] = command_handler
321
-
322
- def add_service_command_handler(self, command_name: str, command_handler: Callable):
323
- self.service_command_handlers[command_name] = command_handler
324
-
325
- async def _do_say(self, cmd: dict[str, Any]) -> list:
326
- self.logger.debug(f"Executing command: '{cmd['command']}")
327
- self.logger.debug(f"Message: {cmd['message']}")
328
- return zmq_string_response(f"Message said: {cmd['message']}")
329
-
330
- async def _do_block(self, cmd: dict[str, Any]) -> list:
331
- self.logger.debug(f"Blocking the commanding for {cmd['sleep']}s...")
332
- await asyncio.sleep(cmd["sleep"])
333
- self.logger.debug(f"Blocking finished after {cmd['sleep']}s.")
334
- return zmq_string_response("block: ACK")
335
-
336
- async def _handle_block(self, cmd: dict[str, Any]) -> list:
337
- self.logger.debug(f"Handling '{cmd['command']}' service request.")
338
- await asyncio.sleep(cmd["sleep"])
339
- self.logger.debug(f"Blocking finished after {cmd['sleep']}s.")
340
- return zmq_string_response("block: ACK")
341
-
342
- async def _handle_ping(self, cmd: dict[str, Any]) -> list:
343
- self.logger.debug(f"Handling '{cmd['command']}' service request.")
344
- return zmq_string_response("pong")
345
-
346
- async def _handle_info(self, cmd: dict[str, Any]) -> list:
347
- self.logger.debug(f"Handling '{cmd['command']}' service request.")
348
- return zmq_json_response(
349
- {
350
- "success": True,
351
- "message": {
352
- "name": type(self).__name__,
353
- "hostname": self.get_ip_address(),
354
- "device commanding port": self.device_command_port,
355
- "service commanding port": self.service_command_port,
356
- },
357
- }
358
- )
359
-
360
- async def _handle_terminate(self, cmd: dict[str, Any]) -> list:
361
- self.logger.debug(f"Handling '{cmd['command']}' request.")
362
-
363
- self.stop()
364
-
365
- return zmq_json_response(
366
- {
367
- "success": True,
368
- "message": {"status": "terminating"},
369
- }
370
- )
371
-
372
- async def process_service_command(self):
373
- self.logger.info("Starting service command processing ...")
374
-
375
- while not self.interrupted.is_set():
376
- try:
377
- # Wait for a request with timeout to allow checking if still running
378
- try:
379
- parts = await asyncio.wait_for(self.service_command_socket.recv_multipart(), timeout=1.0)
380
- except asyncio.TimeoutError:
381
- continue
382
-
383
- if VERBOSE_DEBUG:
384
- self.logger.debug(f"{parts=}")
385
-
386
- # For commanding, we only accept simple commands as a string or a complex command with arguments as
387
- # JSON data. In both cases, there are only three parts in this multipart message.
388
- client_id, sequence_id, message_type, data = parts
389
- if message_type == b"MESSAGE_TYPE:STRING":
390
- service_command = {"command": data.decode("utf-8")}
391
- elif message_type == b"MESSAGE_TYPE:JSON":
392
- service_command = json.loads(data.decode())
393
- else:
394
- filename, lineno, function_name = get_current_location()
395
- # We have an unknown message format, send an error message back
396
- message = zmq_error_response(
397
- {
398
- "success": False,
399
- "message": f"Incorrect message type: {message_type}",
400
- "metadata": {
401
- "data": data.decode(),
402
- "file": filename,
403
- "lineno": lineno,
404
- "function": function_name,
405
- },
406
- }
407
- )
408
- await self.service_command_socket.send_multipart([client_id, sequence_id, *message])
409
- continue
410
-
411
- self.logger.debug(f"Received service request: {service_command}")
412
-
413
- self.logger.debug("Process the command...")
414
- response = await self._process_service_command(service_command)
415
-
416
- self.logger.debug("Send the response...")
417
- await self.service_command_socket.send_multipart([client_id, sequence_id, *response])
418
-
419
- except asyncio.CancelledError:
420
- self.logger.debug("Service command handling task cancelled.")
421
- break
422
-
423
- async def _process_service_command(self, cmd: dict[str, Any]) -> list:
424
- command = cmd.get("command")
425
- if not command:
426
- return zmq_error_response(
427
- {
428
- "success": False,
429
- "message": "no command field provide, don't know what to do.",
430
- }
431
- )
432
-
433
- handler = self.service_command_handlers.get(command)
434
- if not handler:
435
- filename, lineno, function_name = get_current_location()
436
- return zmq_error_response(
437
- {
438
- "success": False,
439
- "message": f"Unknown command: {command}",
440
- "metadata": {"file": filename, "lineno": lineno, "function": function_name},
441
- }
442
- )
443
-
444
- return await handler(cmd)
445
-
446
- async def process_sequential_queue(self):
447
- """
448
- Process operations that need to be executed sequentially.
449
-
450
- When the operation return "Quit" the processing is interrupted.
451
- """
452
-
453
- self.logger.info("Starting sequential queue processing ...")
454
-
455
- while not self.interrupted.is_set():
456
- try:
457
- operation = await asyncio.wait_for(self._sequential_queue.get(), 0.1)
458
- await operation
459
- self._sequential_queue.task_done()
460
- except asyncio.TimeoutError:
461
- continue
462
- except asyncio.CancelledError:
463
- break
464
- except Exception as exc:
465
- self.logger.error(f"Error processing sequential operation: {exc}")
466
-
467
- async def send_status_updates(self):
468
- """
469
- Send status information about the control server and the device connection to the monitoring channel.
470
- """
471
-
472
- self.logger.info("Starting status updates ...")
473
-
474
- async def status():
475
- self.logger.info(f"{datetime.datetime.now()} Sending status updates.")
476
- await asyncio.sleep(0.5) # ideally, should not be larger than periodic interval
477
-
478
- try:
479
- periodic = Periodic(interval=1.0, callback=status)
480
- periodic.start()
481
-
482
- await self.interrupted.wait()
483
-
484
- periodic.stop()
485
-
486
- except asyncio.CancelledError:
487
- self.logger.debug("Caught CancelledError on status updates keep-alive loop.")
488
-
489
- async def enqueue_sequential_operation(self, coroutine_func):
490
- """
491
- Add an operation to the sequential queue.
492
-
493
- Args:
494
- coroutine_func: A coroutine function (async function) to be executed sequentially
495
- """
496
-
497
- if self._sequential_queue is not None: # sanity check
498
- self._sequential_queue.put_nowait(coroutine_func)
499
-
500
-
501
- class DummyAsyncControlServer(AsyncControlServer): ...
502
-
503
-
504
- DEFAULT_CLIENT_REQUEST_TIMEOUT = 5.0 # seconds
505
- """Default timeout for sending requests to the control server."""
506
- DEFAULT_LINGER = 100 # milliseconds
507
- """Default linger for ZeroMQ sockets."""
508
-
509
-
510
- class AsyncControlClient:
511
- def __init__(
512
- self,
513
- endpoint: str | None = None,
514
- service_type: str | None = None,
515
- client_id: str = CONTROL_CLIENT_ID,
516
- timeout: float = DEFAULT_CLIENT_REQUEST_TIMEOUT,
517
- linger: int = DEFAULT_LINGER,
518
- ):
519
- self.logger = logging.getLogger("egse.async_control.client")
520
-
521
- self.endpoint = endpoint
522
- self.service_type = service_type
523
- self.timeout = timeout # seconds
524
- self.linger = linger # milliseconds
525
- self._client_id = f"{client_id}-{uuid.uuid4()}".encode()
526
- self._sequence = 0
527
-
528
- self.context: zmq.asyncio.Context = zmq.asyncio.Context.instance()
529
-
530
- self.device_command_socket: zmq.asyncio.Socket = self.context.socket(zmq.DEALER)
531
- self.service_command_socket: zmq.asyncio.Socket = self.context.socket(zmq.DEALER)
532
-
533
- self.device_command_port: int = 0
534
- self.service_command_port: int = 0
535
-
536
- self._post_init_is_done = False
537
-
538
- async def _post_init(self) -> bool:
539
- """
540
- A post initialisation method that sets the device and service commanding
541
- ports and the endpoint for this client. The information is retrieved from
542
- the service registry.
543
-
544
- Returns:
545
- The method returns True if the device and service commanding port and
546
- the endpoint could be determined from the service registry.
547
-
548
- If the post initialisation step has already been called, a warning is
549
- issued, and the method returns True.
550
-
551
- If no service_type is known or the service_type is not registered,
552
- False is returned.
553
-
554
- """
555
- if self._post_init_is_done:
556
- self.logger.warning("The post_init function is already called, returning.")
557
- return True
558
-
559
- self._post_init_is_done = True
560
- if self.service_type:
561
- with AsyncRegistryClient() as registry:
562
- service = await registry.discover_service(self.service_type)
563
- if service:
564
- hostname = service["host"]
565
- self.device_command_port = port = service["port"]
566
- self.service_command_port = service["metadata"]["service_port"]
567
- self.endpoint = f"tcp://{hostname}:{port}"
568
- return True
569
- else:
570
- return False
571
-
572
- return False
573
-
574
- # Why do we need this create method here?
575
- # The constructor (`__init__`) can not be an async method and to properly initialise the client,
576
- # we need to contact the ServiceRegistry for the hostname and port numbers. The service discovery
577
- # is an async operation.
578
- # Additionally, it's not a good idea to perform such initialisation inside the constructor of the
579
- # class anyway. The class can also be called as an async context manager, in which case the create()
580
- # is not needed and the post initialisation will be done in the `__aenter__` method.
581
-
582
- @classmethod
583
- async def create(cls, service_type: str) -> AsyncControlClient:
584
- """Factory method that creates an AsyncControlClient and collects information about the service it needs to
585
- connect to."""
586
- client = cls(service_type=service_type)
587
- if not await client._post_init():
588
- raise InitializationError(
589
- f"Could not initialise AsyncControlClient, no service_type ({service_type}) found. Will not be "
590
- f"able to connect to the control server."
591
- )
592
- return client
593
-
594
- def connect(self):
595
- self.connect_device_command_socket()
596
- self.connect_service_command_socket()
597
-
598
- def disconnect(self):
599
- self.disconnect_device_command_socket()
600
- self.disconnect_service_command_socket()
601
-
602
- def connect_device_command_socket(self):
603
- if self.endpoint is None:
604
- self.logger.warning("Cannot connect device command socket: endpoint is not defined.")
605
- return
606
- if self.device_command_socket is None:
607
- self.device_command_socket = self.context.socket(zmq.DEALER)
608
- self.device_command_socket.setsockopt(zmq.LINGER, self.linger)
609
- self.device_command_socket.setsockopt(zmq.IDENTITY, self._client_id)
610
- self.device_command_socket.connect(self.endpoint)
611
-
612
- def connect_service_command_socket(self):
613
- if self.endpoint is None:
614
- self.logger.warning("Cannot connect service command socket: endpoint is not defined.")
615
- return
616
-
617
- if self.service_command_port == 0:
618
- self.logger.warning("Service command port is 0 when connecting socket.")
619
-
620
- if self.service_command_socket is None:
621
- self.service_command_socket = self.context.socket(zmq.DEALER)
622
- self.service_command_socket.setsockopt(zmq.LINGER, self.linger)
623
- self.service_command_socket.setsockopt(zmq.IDENTITY, self._client_id)
624
- self.service_command_socket.connect(set_address_port(self.endpoint, self.service_command_port))
625
-
626
- def disconnect_device_command_socket(self):
627
- if self.device_command_socket:
628
- self.device_command_socket.setsockopt(zmq.LINGER, 100)
629
- self.device_command_socket.close()
630
- self.device_command_socket = None
631
-
632
- def disconnect_service_command_socket(self):
633
- if self.service_command_socket:
634
- self.service_command_socket.setsockopt(zmq.LINGER, 100)
635
- self.service_command_socket.close()
636
- self.service_command_socket = None
637
-
638
- async def __aenter__(self):
639
- if not self._post_init_is_done:
640
- if not await self._post_init():
641
- raise InitializationError(
642
- f"Could not initialise AsyncControlClient, no service_type ({self.service_type}) found. Will not "
643
- f"be able to connect to the control server."
644
- )
645
- return self
646
-
647
- self.connect()
648
- return self
649
-
650
- async def __aexit__(self, exc_type, exc_val, exc_tb):
651
- self.disconnect()
652
-
653
- async def do(self, cmd: dict[str, Any], timeout: float = 5.0) -> dict[str, Any]:
654
- """
655
- Sends a device command to the control server and waits for a response.
656
-
657
- Args:
658
- cmd (dict): The command to send. Should contain at least a 'command' key.
659
- timeout (float, optional): Maximum time to wait for a response in seconds. Defaults to 5.0.
660
-
661
- Returns:
662
- dict: The response from the control server. Contains at least:
663
- - 'success' (bool): True if the command was processed successfully, False otherwise.
664
- - 'message' (str or dict): The server's response or an error message.
665
-
666
- Notes:
667
- If the request times out or a socket error occurs, the method attempts
668
- to reset the sockets and returns a response with 'success' set to False
669
- and an appropriate error message.
670
- """
671
- response = await self._send_request(SocketType.DEVICE, cmd, timeout)
672
- return response
673
-
674
- async def block(self, sleep: int, timeout: int | None = None) -> str | None:
675
- cmd = {"command": "block", "sleep": sleep}
676
- response = await self._send_request(SocketType.SERVICE, cmd, timeout=timeout)
677
- if response["success"]:
678
- return response["message"]
679
- else:
680
- self.logger.error(f"Server returned an error: {response['message']}")
681
- return None
682
-
683
- async def ping(self, timeout: int | None = None) -> str | None:
684
- response = await self._send_request(SocketType.SERVICE, "ping", timeout=timeout)
685
- if response["success"]:
686
- return response["message"]
687
- else:
688
- self.logger.error(f"Server returned an error: {response['message']}")
689
- return None
690
-
691
- async def info(self) -> str | None:
692
- response = await self._send_request(SocketType.SERVICE, "info")
693
- if response["success"]:
694
- return response["message"]
695
- else:
696
- self.logger.error(f"Server returned an error: {response['message']}")
697
- return None
698
-
699
- async def stop_server(self) -> dict | None:
700
- response = await self._send_request(SocketType.SERVICE, "terminate")
701
- if response["success"]:
702
- return response["message"]
703
- else:
704
- self.logger.error(f"Server returned an error: {response['message']}")
705
- return None
706
-
707
- async def _send_request(
708
- self,
709
- socket_type: SocketType,
710
- request: dict[str, Any] | str,
711
- timeout: float | None = None,
712
- ) -> dict[str, Any]:
713
- """
714
- Send a request to the control server and get the response.
715
-
716
- A request can be a string with a simple command, e.g. 'ping', or it can be a dictionary
717
- in which case it will be sent as a JSON request. The dictionary shall have the following format:
718
-
719
- request = {
720
- 'command': <the command string without arguments>,
721
- 'args': [*args],
722
- 'kwargs': {**kwargs},
723
- }
724
-
725
- The response from the server will always be a dictionary with at least the following structure:
726
-
727
- response = {
728
- 'success': <True or False>,
729
- 'message': <The content of the data returned by the server>,
730
- }
731
-
732
- Args:
733
- socket_type: socket type to use for the command request.
734
- request: The request to send to the control server.
735
- timeout: how many seconds before the request times out.
736
-
737
- Returns:
738
- The response from the control server as a dictionary.
739
- """
740
-
741
- timeout = timeout or self.timeout
742
-
743
- socket = self._get_socket(socket_type)
744
- self._sequence += 1
745
- try:
746
- if socket is None:
747
- raise RuntimeError("Socket of the AsyncControlClient is not initialized.")
748
-
749
- if isinstance(request, str):
750
- message = zmq_string_request(request)
751
- elif isinstance(request, dict):
752
- message = zmq_json_request(request)
753
- else:
754
- raise ValueError(f"request argument shall be a string or a dictionary, not {type(request)}.")
755
-
756
- if VERBOSE_DEBUG:
757
- self.logger.debug(f"Sending multipart message: {message}")
758
-
759
- await socket.send_multipart([str(self._sequence).encode(), *message])
760
-
761
- while True:
762
- sequence, msg_type, data = await asyncio.wait_for(socket.recv_multipart(), timeout=timeout)
763
-
764
- if VERBOSE_DEBUG:
765
- self.logger.debug(f"Received multipart message: {sequence}, {msg_type}, {data}")
766
-
767
- if int(sequence.decode()) < self._sequence:
768
- self.logger.warning(
769
- f"Received a reply from a previous command: {int(sequence.decode())}, {msg_type}, {data}, "
770
- f"current sequence id = {self._sequence}"
771
- )
772
- continue
773
- elif int(sequence.decode()) > self._sequence:
774
- self.logger.error(
775
- f"Protocol violation: received reply for future request {sequence} "
776
- f"(current sequence: {self._sequence}). "
777
- f"Possible bug or multiple clients with the same identity."
778
- )
779
- raise RuntimeError(
780
- f"Protocol violation: "
781
- f"Got a reply with a future sequence id {sequence}, current sequence id is {self._sequence}."
782
- )
783
-
784
- if msg_type == b"MESSAGE_TYPE:STRING":
785
- return {"success": True, "message": data.decode("utf-8")}
786
- elif msg_type == b"MESSAGE_TYPE:JSON":
787
- return json.loads(data)
788
- elif msg_type == b"MESSAGE_TYPE:ERROR":
789
- return pickle.loads(data)
790
- else:
791
- msg = f"Unknown server response message type: {msg_type}"
792
- self.logger.error(msg)
793
- return {"success": False, "message": msg}
794
-
795
- except asyncio.TimeoutError:
796
- self.logger.error(f"{socket_type.name} request timed out after {timeout:.3f}s")
797
- self.recreate_socket(socket_type)
798
- return {
799
- "success": False,
800
- "message": f"Request timed out after {timeout:.3f}s",
801
- }
802
-
803
- except zmq.ZMQError as exc:
804
- self.logger.error(f"ZMQ error: {exc}")
805
- self.recreate_socket(socket_type)
806
- return {
807
- "success": False,
808
- "message": f"ZMQError: {exc}",
809
- }
810
-
811
- except Exception as exc:
812
- self.logger.error(f"Error sending request: {type(exc).__name__} – {exc}")
813
- traceback = Traceback.from_exception(
814
- type(exc),
815
- exc,
816
- exc.__traceback__,
817
- show_locals=True, # Optional: show local variables
818
- width=None, # Optional: use full width
819
- extra_lines=3, # Optional: context lines
820
- )
821
- log_rich_output(self.logger, logging.ERROR, traceback)
822
- return {"success": False, "message": str(exc)}
823
-
824
- def _get_socket(self, socket_type: SocketType):
825
- match socket_type:
826
- case SocketType.DEVICE:
827
- return self.device_command_socket
828
- case SocketType.SERVICE:
829
- return self.service_command_socket
830
-
831
- def recreate_socket(self, socket_type: SocketType):
832
- if RECREATE_SOCKET:
833
- self.logger.warning(f"Recreating {socket_type.name} socket ...")
834
- match socket_type:
835
- case SocketType.DEVICE:
836
- self.disconnect_device_command_socket()
837
- self.connect_device_command_socket()
838
- case SocketType.SERVICE:
839
- self.disconnect_service_command_socket()
840
- self.connect_service_command_socket()
841
- else:
842
- self.logger.debug("Socket recreation after timeout has been disabled.")
843
-
844
-
845
- async def cs_test_device_command_timeouts():
846
- """
847
- This test is about timeouts on the client and blocking commands on the server
848
- for *device commands*.
849
-
850
- We start a control server which will register to the service registry and
851
- send out heartbeats.
852
-
853
- We start a control client that will connect to the server through its service type.
854
- Then we send the following commands, from the client perspective:
855
-
856
- - send a 'block' device command of 8s and a timeout of 3s. This will block the
857
- device commanding part on the server for ten seconds. The client will
858
- time out on this command after three seconds.
859
- - sleep for ten seconds.
860
- - send a 'say' command with a timeout of 2s. This will return immediately.
861
-
862
- The client should properly discard the server reply from the timed out block
863
- command. You should see a warning message saying a reply was received from a
864
- previous command. The 'say' command should receive its response as expected.
865
-
866
- """
867
-
868
- # First start the control server as a background task.
869
- server = AsyncControlServer()
870
- server_task = asyncio.create_task(server.start())
871
-
872
- logger.info("Starting Asynchronous Control Server ...")
873
-
874
- # Give the control server the time to start up
875
- logger.info("Sleep for 0.5s...")
876
- await asyncio.sleep(0.5)
877
-
878
- # As of now, the server_task is running 'in the background' in the event loop.
879
-
880
- # Now create a control client that will connect to the above server.
881
- async with AsyncControlClient(service_type=CONTROL_SERVER_SERVICE_TYPE) as client:
882
- # Sleep some time, so we can see the control server in action, e.g. status reports, housekeeping, etc
883
- logger.info("Sleep for 5s...")
884
- await asyncio.sleep(5.0)
885
-
886
- logger.info("Send a blocking device command, duration is 8s, timeout is 3s.")
887
- response = await client.do({"command": "block", "sleep": 8}, timeout=3)
888
- logger.info(f"block: {response=}")
889
-
890
- if response["success"] or "timed out" not in response["message"]:
891
- logger.error(f"Did not get expected message from server: {response['message']}")
892
-
893
- logger.info("Sleep for 10s...")
894
- await asyncio.sleep(10.0)
895
-
896
- logger.info("Send a 'say' device command, timeout is 2s.")
897
- response = await client.do({"command": "say", "message": "Hello, World!"}, timeout=2.0)
898
- logger.info(f"say: {response=}")
899
-
900
- if not response["success"] or "Hello, World!" not in response["message"]:
901
- logger.error(f"Did not get expected message from server: {response['message']}")
902
-
903
- is_active = await is_control_server_active(service_type=CONTROL_SERVER_SERVICE_TYPE)
904
- logger.info(f"Server status: {'active' if is_active else 'unreachable'}")
905
-
906
- logger.info("Sleeping 1s before terminating the server...")
907
- await asyncio.sleep(1.0)
908
-
909
- logger.info("Terminating the server.")
910
- response = await client.stop_server()
911
- logger.info(f"stop_server: {response = }")
912
-
913
- is_active = await is_control_server_active(service_type=CONTROL_SERVER_SERVICE_TYPE)
914
- logger.info(f"Server status: {'active' if is_active else 'unreachable'}")
915
-
916
- await server_task
917
-
918
- is_active = await is_control_server_active(service_type=CONTROL_SERVER_SERVICE_TYPE)
919
- logger.info(f"Server status: {'active' if is_active else 'unreachable'}")
920
-
921
-
922
- async def cs_test_service_command_timeouts():
923
- """
924
- This test is about timeouts on the client and blocking commands on the server
925
- for *service commands*.
926
-
927
- We start a control server which will register to the service registry and
928
- send out heartbeats.
929
-
930
- We start a control client that will connect to the server through its service type.
931
- Then we send the following commands, from the client perspective:
932
-
933
- - send a 'block' service command of 8s and a timeout of 3s. This will block the
934
- service commanding part on the server for ten seconds. The client will
935
- time out on this command after three seconds.
936
- - sleep for ten seconds.
937
- - send a 'info' command with a timeout of 2s. This will return immediately.
938
-
939
- The client should properly discard the server reply from the timed out block
940
- command. You should see a warning message saying a reply was received from a
941
- previous command. The 'info' command should receive its response as expected.
942
-
943
- """
944
-
945
- # First start the control server as a background task.
946
- server = AsyncControlServer()
947
- server_task = asyncio.create_task(server.start())
948
-
949
- logger.info("Starting Asynchronous Control Server ...")
950
-
951
- # Give the control server the time to start up
952
- logger.info("Sleep for 0.5s...")
953
- await asyncio.sleep(0.5)
954
-
955
- # As of now, the server_task is running 'in the background' in the event loop.
956
-
957
- # Now create a control client that will connect to the above server.
958
- async with AsyncControlClient(service_type=CONTROL_SERVER_SERVICE_TYPE) as client:
959
- # Sleep some time, so we can see the control server in action, e.g. status reports, housekeeping, etc
960
- logger.info("Sleep for 5s...")
961
- await asyncio.sleep(5.0)
962
-
963
- logger.info("Send a blocking service command, duration is 8s, timeout is 3s.")
964
- response = await client.block(sleep=8, timeout=3)
965
- logger.info(f"service block: {response=}")
966
-
967
- logger.info("Sleep for 10s...")
968
- await asyncio.sleep(10.0)
969
-
970
- logger.info("Get info on the control server.")
971
- response = await client.info()
972
- logger.info(f"service info: {response=}")
973
-
974
- is_active = await is_control_server_active(service_type=CONTROL_SERVER_SERVICE_TYPE)
975
- logger.info(f"Server status: {'active' if is_active else 'unreachable'}")
976
-
977
- logger.info("Sleeping 1s before terminating the server...")
978
- await asyncio.sleep(1.0)
979
-
980
- logger.info("Terminating the server.")
981
- response = await client.stop_server()
982
- logger.info(f"stop_server: {response = }")
983
-
984
- is_active = await is_control_server_active(service_type=CONTROL_SERVER_SERVICE_TYPE)
985
- logger.info(f"Server status: {'active' if is_active else 'unreachable'}")
986
-
987
- await server_task
988
-
989
- is_active = await is_control_server_active(service_type=CONTROL_SERVER_SERVICE_TYPE)
990
- logger.info(f"Server status: {'active' if is_active else 'unreachable'}")
991
-
992
-
993
- async def control_server_test():
994
- # First start the control server as a background task.
995
- server = AsyncControlServer()
996
- server_task = asyncio.create_task(server.start())
997
-
998
- logger.info("Starting Asynchronous Control Server ...")
999
-
1000
- # Give the control server the time to start up
1001
- logger.info("Sleep for 0.5s...")
1002
- await asyncio.sleep(0.5)
1003
-
1004
- # As of now, the server_task is running 'in the background' in the event loop.
1005
-
1006
- # Now create a control client that will connect to the above server.
1007
- async with AsyncControlClient(service_type=CONTROL_SERVER_SERVICE_TYPE) as client:
1008
- # Sleep some time, so we can see the control server in action, e.g. status reports, housekeeping, etc
1009
- logger.info("Sleep for 5s...")
1010
- await asyncio.sleep(5.0)
1011
-
1012
- logger.info("Send a 'ping' service command...")
1013
- response = await client.ping()
1014
- logger.info(f"ping service command: {response = }") # should be: response = 'pong'
1015
-
1016
- logger.info("Send an 'info' service command...")
1017
- response = await client.info()
1018
- logger.info(f"info service command: {response = }") # should be: response = {'name': 'AsyncControlServer', ...
1019
-
1020
- logger.info("Send an 'info' device command...")
1021
- # info() is a service command and not a device command, so this will fail.
1022
- response = await client.do({"command": "info"})
1023
- # should be: response={'success': False, 'message': 'Unknown command: info', ...
1024
- logger.info(f"info device command: {response=}")
1025
-
1026
- # this will block commanding since device commands are executed in the same task
1027
- # the do() will timeout after 3s, but the block command will run for 10s on the
1028
- # server, and it will send back an ACK (which will be caught and interpreted by
1029
- # –in this case– the seconds do 'say' command, which will not yet have timed out.
1030
- logger.info("Send a blocking device command, duration is 9s, timeout is 3s.")
1031
- response = await client.do({"command": "block", "sleep": 9}, timeout=3.0)
1032
- # should be: response={'success': False, 'message': 'Request timed out after 3.000s'}
1033
- logger.info(f"Blocking device command: {response=}")
1034
-
1035
- # ping() is a service command, so this is not blocked by the above block() command
1036
- logger.info("Send a 'ping' service command...")
1037
- response = await client.ping()
1038
- logger.info(f"ping service command: {response=}")
1039
-
1040
- # say() is a device command and will time out after 2s because the above blocking
1041
- # block() command is still running on the server
1042
- logger.info("Send a 'say' device command, timeout is 2s.")
1043
- response = await client.do({"command": "say", "message": "Hello, World!"}, timeout=2.0)
1044
- logger.info(f"say device command: {response=}")
1045
-
1046
- # Sleep some time, so we can see the control server in action, e.g. status reports, housekeeping, etc
1047
- logger.info("Sleep for 10s...")
1048
- await asyncio.sleep(10.0)
1049
-
1050
- # This time we won't let the command time out...
1051
- logger.info("Send a 'say' device command, timeout is 2s.")
1052
- response = await client.do({"command": "say", "message": "Hello, Again!"}, timeout=2.0)
1053
- logger.info(f"say device command: {response=}")
1054
-
1055
- is_active = await is_control_server_active(service_type=CONTROL_SERVER_SERVICE_TYPE)
1056
- logger.info(f"Server status: {'active' if is_active else 'unreachable'}")
1057
-
1058
- logger.info("Sleeping 1s before terminating the server...")
1059
- await asyncio.sleep(1.0)
1060
-
1061
- logger.info("Terminating the server.")
1062
- response = await client.stop_server()
1063
- logger.info(f"stop_server: {response = }")
1064
-
1065
- is_active = await is_control_server_active(service_type=CONTROL_SERVER_SERVICE_TYPE)
1066
- logger.info(f"Server status: {'active' if is_active else 'unreachable'}")
1067
-
1068
- await server_task
1069
-
1070
- is_active = await is_control_server_active(service_type=CONTROL_SERVER_SERVICE_TYPE)
1071
- logger.info(f"Server status: {'active' if is_active else 'unreachable'}")
1072
-
1073
-
1074
- if __name__ == "__main__":
1075
- # The statement logging.captureWarnings(True) redirects Python's warnings module output
1076
- # (such as warnings.warn(...)) to the logging system, so warnings appear in your logs
1077
- # instead of just printing to stderr. This is useful for unified logging and easier
1078
- # debugging, especially in larger applications.
1079
- logging.captureWarnings(True)
1080
-
1081
- try:
1082
- # main_task = asyncio.run(cs_test_service_command_timeouts())
1083
- main_task = asyncio.run(control_server_test())
1084
- except KeyboardInterrupt:
1085
- print("Caught KeyboardInterrupt, terminating.")