Coverage for src/cgse_dummy/dummy_cs.py: 64%
151 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-11 22:34 +0200
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-11 22:34 +0200
1"""
2The control server for the dummy device.
3"""
5import logging
6import multiprocessing
7import random
8import sys
9import threading
10import time
11from functools import partial
13import typer
14import zmq
15from egse.command import ClientServerCommand
16from egse.confman import is_configuration_manager_active
17from egse.control import ControlServer
18from egse.control import is_control_server_active
19from egse.decorators import dynamic_interface
20from egse.listener import Event
21from egse.listener import EventInterface
22from egse.protocol import CommandProtocol
23from egse.proxy import Proxy
24from egse.settings import Settings
25from egse.storage import TYPES
26from egse.storage import is_storage_manager_active
27from egse.storage import register_to_storage_manager
28from egse.storage import store_housekeeping_information
29from egse.storage import unregister_from_storage_manager
30from egse.system import attrdict
31from egse.system import format_datetime
32from egse.zmq_ser import bind_address
33from egse.zmq_ser import connect_address
35from cgse_dummy.dummy_dev import Dummy
37logger = logging.getLogger("egse.dummy")
39cs_settings = Settings.load("DUMMY CS")
40dev_settings = Settings.load("DUMMY DEVICE")
42DEV_HOST = "localhost"
43"""The hostname or IP address of the Dummy Device."""
44DEV_PORT = dev_settings.PORT
45"""The port number for the Dummy Device."""
46DEV_NAME = f"Dummy Device {dev_settings.MODEL}"
47"""The name used for the Dummy Device, this is used in Exceptions and in the info command."""
49READ_TIMEOUT = 10.0 # seconds
50"""The maximum time to wait for a socket receive command."""
51WRITE_TIMEOUT = 1.0 # seconds
52"""The maximum time to wait for a socket send command."""
53CONNECT_TIMEOUT = 3.0 # seconds
54"""The maximum time to wait for establishing a socket connect."""
56# Especially DummyCommand and DummyController need to be defined in a known module
57# because those objects are pickled and when de-pickled at the clients side the class
58# definition must be known.
60commands = attrdict(
61 {
62 "info": {
63 "description": "Info on the Dummy Device.",
64 "response": "handle_device_method",
65 },
66 "get_value": {
67 "description": "Read a value from the device.",
68 },
69 "handle_event": {
70 "description": "Notification of an event",
71 "device_method": "handle_event",
72 "cmd": "{event}",
73 "response": "handle_device_method",
74 },
75 }
76)
78app = typer.Typer(
79 help=f"Dummy control server for the dummy device {dev_settings.MODEL}"
80)
83def is_dummy_cs_active():
84 return is_control_server_active(
85 endpoint=connect_address(
86 cs_settings.PROTOCOL, cs_settings.HOSTNAME, cs_settings.COMMANDING_PORT
87 )
88 )
91class DummyCommand(ClientServerCommand):
92 pass
95class DummyInterface:
96 @dynamic_interface
97 def info(self): ...
99 @dynamic_interface
100 def get_value(self, *args, **kwargs): ...
103class DummyProxy(Proxy, DummyInterface, EventInterface):
104 def __init__(
105 self,
106 protocol=cs_settings.PROTOCOL,
107 hostname=cs_settings.HOSTNAME,
108 port=cs_settings.COMMANDING_PORT,
109 timeout=cs_settings.TIMEOUT,
110 ):
111 """
112 Args:
113 protocol: the transport protocol [default is taken from settings file]
114 hostname: location of the control server (IP address) [default is taken from settings file]
115 port: TCP port on which the control server is listening for commands [default is taken from settings file]
116 """
117 super().__init__(connect_address(protocol, hostname, port), timeout=timeout)
120class DummyController(DummyInterface, EventInterface):
121 def __init__(self, control_server):
122 self._cs = control_server
123 self._dev = Dummy(DEV_HOST, DEV_PORT)
124 self._dev.connect()
126 def info(self) -> str:
127 return self._dev.trans("info").decode().strip()
129 def get_value(self) -> float:
130 return float(self._dev.trans("get_value").decode().strip())
132 def handle_event(self, event: Event) -> str:
133 _exec_in_thread = False
135 def _handle_event(_event):
136 logger.info(f"An event is received, {_event=}")
137 logger.info(f"CM CS active? {is_configuration_manager_active()}")
138 time.sleep(5.0)
139 logger.info(f"CM CS active? {is_configuration_manager_active()}")
140 logger.info(f"An event is processed, {_event=}")
142 if _exec_in_thread:
143 # We execute this function in a daemon thread so the acknowledgment is
144 # sent back immediately (the ACK means 'command received and will be
145 # executed').
147 retry_thread = threading.Thread(target=_handle_event, args=(event,))
148 retry_thread.daemon = True
149 retry_thread.start()
150 else:
151 # An alternative to the daemon thread is to create a scheduled task that will be executed
152 # after the event is acknowledged.
154 self._cs.schedule_task(partial(_handle_event, event))
156 return "ACK"
159class DummyProtocol(CommandProtocol):
160 def __init__(self, control_server: ControlServer):
161 super().__init__()
162 self.control_server = control_server
164 self.device_controller = DummyController(control_server)
166 self.load_commands(commands, DummyCommand, DummyController)
168 self.build_device_method_lookup_table(self.device_controller)
170 self._count = 0
172 def get_bind_address(self):
173 return bind_address(
174 self.control_server.get_communication_protocol(),
175 self.control_server.get_commanding_port(),
176 )
178 def get_status(self):
179 return super().get_status()
181 def get_housekeeping(self) -> dict:
182 # logger.debug(f"Executing get_housekeeping function for {self.__class__.__name__}.")
184 self._count += 1
186 # use the sleep to test the responsiveness of the control server when even this get_housekeeping function takes
187 # a lot of time, i.e. up to several minutes in the case of data acquisition devices
188 # import time
189 # time.sleep(2.0)
191 return {
192 "timestamp": format_datetime(),
193 "COUNT": self._count,
194 "PI": 3.14159, # just to have a constant parameter
195 "Random": random.randint(0, 100), # just to have a variable parameter
196 "T (ºC)": self.device_controller.get_value(),
197 }
199 def quit(self):
200 logger.info("Executing 'quit()' on DummyProtocol.")
203class DummyControlServer(ControlServer):
204 """
205 DummyControlServer - Command and monitor dummy device controllers.
207 The sever binds to the following ZeroMQ sockets:
209 * a REQ-REP socket that can be used as a command server. Any client can connect and
210 send a command to the dummy device controller.
212 * a PUB-SUP socket that serves as a monitoring server. It will send out status
213 information to all the connected clients every HK_DELAY seconds.
215 """
217 def __init__(self):
218 multiprocessing.current_process().name = "dummy_cs"
220 super().__init__()
222 self.device_protocol = DummyProtocol(self)
224 logger.info(
225 f"Binding ZeroMQ socket to {self.device_protocol.get_bind_address()} for {self.__class__.__name__}"
226 )
228 self.device_protocol.bind(self.dev_ctrl_cmd_sock)
230 self.poller.register(self.dev_ctrl_cmd_sock, zmq.POLLIN)
232 self.set_hk_delay(cs_settings.HK_DELAY)
234 from egse.confman import ConfigurationManagerProxy
235 from egse.listener import EVENT_ID
237 # The following import is needed because without this import, DummyProxy would be <class '__main__.DummyProxy'>
238 # instead of `egse.dummy.DummyProxy` and the ConfigurationManager control server will not be able to de-pickle
239 # the register message.
240 from egse.dummy import DummyProxy # noqa
242 self.register_as_listener(
243 proxy=ConfigurationManagerProxy,
244 listener={
245 "name": "Dummy CS",
246 "proxy": DummyProxy,
247 "event_id": EVENT_ID.SETUP,
248 },
249 )
251 def get_communication_protocol(self):
252 return "tcp"
254 def get_commanding_port(self):
255 return cs_settings.COMMANDING_PORT
257 def get_service_port(self):
258 return cs_settings.SERVICE_PORT
260 def get_monitoring_port(self):
261 return cs_settings.MONITORING_PORT
263 def get_storage_mnemonic(self):
264 return "DUMMY-HK"
266 def after_serve(self):
267 logger.debug("After Serve: unregistering Dummy CS")
269 from egse.confman import ConfigurationManagerProxy
271 self.unregister_as_listener(
272 proxy=ConfigurationManagerProxy, listener={"name": "Dummy CS"}
273 )
275 def is_storage_manager_active(self):
276 return is_storage_manager_active()
278 def store_housekeeping_information(self, data):
279 """Send housekeeping information to the Storage manager."""
281 store_housekeeping_information(origin=cs_settings.STORAGE_MNEMONIC, data=data)
283 def register_to_storage_manager(self) -> None:
284 register_to_storage_manager(
285 origin=cs_settings.STORAGE_MNEMONIC,
286 persistence_class=TYPES["CSV"],
287 prep={
288 "column_names": list(self.device_protocol.get_housekeeping().keys()),
289 "mode": "a",
290 },
291 )
293 def unregister_from_storage_manager(self) -> None:
294 unregister_from_storage_manager(origin=cs_settings.STORAGE_MNEMONIC)
297@app.command()
298def start():
299 """Start the dummy control server on localhost."""
301 # The following import is needed because without this import, the control server and Proxy will not be able to
302 # instantiate classes that are passed in ZeroMQ messages and de-pickled.
303 from cgse_dummy.dummy_cs import DummyControlServer # noqa
305 try:
306 control_server = DummyControlServer()
307 control_server.serve()
308 except KeyboardInterrupt:
309 print("Shutdown requested...exiting")
310 except SystemExit as exit_code:
311 print(f"System Exit with code {exit_code}.")
312 sys.exit(-1)
313 except Exception: # noqa
314 import traceback
316 traceback.print_exc(file=sys.stdout)
319@app.command()
320def stop():
321 """Send a quit service command to the dummy control server."""
322 with DummyProxy() as dummy:
323 logger.info("Sending quit_server() to Dummy CS.")
324 sp = dummy.get_service_proxy()
325 sp.quit_server()
328if __name__ == "__main__":
329 app()