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

1""" 

2The control server for the dummy device. 

3""" 

4 

5import logging 

6import multiprocessing 

7import random 

8import sys 

9import threading 

10import time 

11from functools import partial 

12 

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 

34 

35from cgse_dummy.dummy_dev import Dummy 

36 

37logger = logging.getLogger("egse.dummy") 

38 

39cs_settings = Settings.load("DUMMY CS") 

40dev_settings = Settings.load("DUMMY DEVICE") 

41 

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.""" 

48 

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.""" 

55 

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. 

59 

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) 

77 

78app = typer.Typer( 

79 help=f"Dummy control server for the dummy device {dev_settings.MODEL}" 

80) 

81 

82 

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 ) 

89 

90 

91class DummyCommand(ClientServerCommand): 

92 pass 

93 

94 

95class DummyInterface: 

96 @dynamic_interface 

97 def info(self): ... 

98 

99 @dynamic_interface 

100 def get_value(self, *args, **kwargs): ... 

101 

102 

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) 

118 

119 

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() 

125 

126 def info(self) -> str: 

127 return self._dev.trans("info").decode().strip() 

128 

129 def get_value(self) -> float: 

130 return float(self._dev.trans("get_value").decode().strip()) 

131 

132 def handle_event(self, event: Event) -> str: 

133 _exec_in_thread = False 

134 

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=}") 

141 

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'). 

146 

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. 

153 

154 self._cs.schedule_task(partial(_handle_event, event)) 

155 

156 return "ACK" 

157 

158 

159class DummyProtocol(CommandProtocol): 

160 def __init__(self, control_server: ControlServer): 

161 super().__init__() 

162 self.control_server = control_server 

163 

164 self.device_controller = DummyController(control_server) 

165 

166 self.load_commands(commands, DummyCommand, DummyController) 

167 

168 self.build_device_method_lookup_table(self.device_controller) 

169 

170 self._count = 0 

171 

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 ) 

177 

178 def get_status(self): 

179 return super().get_status() 

180 

181 def get_housekeeping(self) -> dict: 

182 # logger.debug(f"Executing get_housekeeping function for {self.__class__.__name__}.") 

183 

184 self._count += 1 

185 

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) 

190 

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 } 

198 

199 def quit(self): 

200 logger.info("Executing 'quit()' on DummyProtocol.") 

201 

202 

203class DummyControlServer(ControlServer): 

204 """ 

205 DummyControlServer - Command and monitor dummy device controllers. 

206 

207 The sever binds to the following ZeroMQ sockets: 

208 

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. 

211 

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. 

214 

215 """ 

216 

217 def __init__(self): 

218 multiprocessing.current_process().name = "dummy_cs" 

219 

220 super().__init__() 

221 

222 self.device_protocol = DummyProtocol(self) 

223 

224 logger.info( 

225 f"Binding ZeroMQ socket to {self.device_protocol.get_bind_address()} for {self.__class__.__name__}" 

226 ) 

227 

228 self.device_protocol.bind(self.dev_ctrl_cmd_sock) 

229 

230 self.poller.register(self.dev_ctrl_cmd_sock, zmq.POLLIN) 

231 

232 self.set_hk_delay(cs_settings.HK_DELAY) 

233 

234 from egse.confman import ConfigurationManagerProxy 

235 from egse.listener import EVENT_ID 

236 

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 

241 

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 ) 

250 

251 def get_communication_protocol(self): 

252 return "tcp" 

253 

254 def get_commanding_port(self): 

255 return cs_settings.COMMANDING_PORT 

256 

257 def get_service_port(self): 

258 return cs_settings.SERVICE_PORT 

259 

260 def get_monitoring_port(self): 

261 return cs_settings.MONITORING_PORT 

262 

263 def get_storage_mnemonic(self): 

264 return "DUMMY-HK" 

265 

266 def after_serve(self): 

267 logger.debug("After Serve: unregistering Dummy CS") 

268 

269 from egse.confman import ConfigurationManagerProxy 

270 

271 self.unregister_as_listener( 

272 proxy=ConfigurationManagerProxy, listener={"name": "Dummy CS"} 

273 ) 

274 

275 def is_storage_manager_active(self): 

276 return is_storage_manager_active() 

277 

278 def store_housekeeping_information(self, data): 

279 """Send housekeeping information to the Storage manager.""" 

280 

281 store_housekeeping_information(origin=cs_settings.STORAGE_MNEMONIC, data=data) 

282 

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 ) 

292 

293 def unregister_from_storage_manager(self) -> None: 

294 unregister_from_storage_manager(origin=cs_settings.STORAGE_MNEMONIC) 

295 

296 

297@app.command() 

298def start(): 

299 """Start the dummy control server on localhost.""" 

300 

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 

304 

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 

315 

316 traceback.print_exc(file=sys.stdout) 

317 

318 

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() 

326 

327 

328if __name__ == "__main__": 

329 app()