Coverage for /Users/rik/github/cgse/libs/cgse-common/src/egse/device.py: 65%

113 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-15 11:57 +0200

1""" 

2This module defines the generic interfaces to connect devices. 

3""" 

4 

5from enum import IntEnum 

6 

7from egse.decorators import dynamic_interface 

8from egse.exceptions import Error 

9 

10 

11class DeviceConnectionState(IntEnum): 

12 """Defines connection states for device connections.""" 

13 

14 # We do not use zero '0' as the connected state to prevent a state to be set 

15 # to connected by default without it explicitly being set. Therefore, 0 will 

16 # be the state where the connection is not explicitly set. 

17 

18 DEVICE_CONNECTION_NOT_SET = 0 

19 DEVICE_CONNECTED = 1 

20 """The device is connected.""" 

21 DEVICE_NOT_CONNECTED = 2 

22 """The device is not connected.""" 

23 

24 

25class DeviceError(Error): 

26 """Generic device error. 

27 

28 Args: 

29 device_name (str): The name of the device 

30 message (str): a clear and brief description of the problem 

31 """ 

32 

33 def __init__(self, device_name: str, message: str): 

34 self.device_name = device_name 

35 self.message = message 

36 

37 def __str__(self): 

38 return f"{self.device_name}: {self.message}" 

39 

40 

41class DeviceControllerError(DeviceError): 

42 """Any error that is returned by the device controller. 

43 

44 When the device controller is connected through an e.g. Ethernet interface, it will usually 

45 return error codes as part of the response to a command. When such an error is returned, 

46 raise this `DeviceControllerError` instead of passing the return code (response) to the caller. 

47 

48 Args: 

49 device_name (str): The name of the device 

50 message (str): a clear and brief description of the problem 

51 """ 

52 

53 def __init__(self, device_name: str, message: str): 

54 super().__init__(device_name, message) 

55 

56 

57class DeviceConnectionError(DeviceError): 

58 """A generic error for all connection type of problems. 

59 

60 Args: 

61 device_name (str): The name of the device 

62 message (str): a clear and brief description of the problem 

63 """ 

64 

65 def __init__(self, device_name: str, message: str): 

66 super().__init__(device_name, message) 

67 

68 

69class DeviceTimeoutError(DeviceError): 

70 """A timeout on a device that we could not handle. 

71 

72 Args: 

73 device_name (str): The name of the device 

74 message (str): a clear and brief description of the problem 

75 """ 

76 

77 def __init__(self, device_name: str, message: str): 

78 super().__init__(device_name, message) 

79 

80 

81class DeviceInterfaceError(DeviceError): 

82 """Any error that is returned or raised by the higher level interface to the device. 

83 

84 Args: 

85 device_name (str): The name of the device 

86 message (str): a clear and brief description of the problem 

87 """ 

88 

89 def __init__(self, device_name: str, message: str): 

90 super().__init__(device_name, message) 

91 

92 

93class DeviceConnectionObserver: 

94 """ 

95 An observer for the connection state of a device. Add the subclass of this class to 

96 the class that inherits from DeviceConnectionObservable. The observable will notify an 

97 update of its state by calling the `update_connection_state()` method. 

98 """ 

99 

100 def __init__(self): 

101 self._state = DeviceConnectionState.DEVICE_NOT_CONNECTED 

102 

103 def update_connection_state(self, state: DeviceConnectionState): 

104 """Updates the connection state with the given state.""" 

105 self._state = state 

106 

107 @property 

108 def state(self): 

109 """Returns the current connection state of the device.""" 

110 return self._state 

111 

112 

113class DeviceConnectionObservable: 

114 """ 

115 An observable for the connection state of a device. An observer can be added with the 

116 `add_observer()` method. Whenever the connection state of the device changes, the subclass 

117 is responsible for notifying the observers by calling the `notify_observers()` method 

118 with the correct state. 

119 """ 

120 

121 def __init__(self): 

122 self._observers: list[DeviceConnectionObserver] = [] 

123 

124 def add_observer(self, observer: DeviceConnectionObserver): 

125 """Add an observer.""" 

126 if observer not in self._observers: 

127 self._observers.append(observer) 

128 

129 def delete_observer(self, observer: DeviceConnectionObserver): 

130 self._observers.remove(observer) 

131 

132 def notify_observers(self, state: DeviceConnectionState): 

133 """Notify the observers of a possible state change.""" 

134 for observer in self._observers: 

135 observer.update_connection_state(state) 

136 

137 def get_observers(self) -> list[DeviceConnectionObserver]: 

138 """Returns a copy of the registered observers.""" 

139 return self._observers.copy() 

140 

141 

142class DeviceConnectionInterface(DeviceConnectionObservable): 

143 """Generic connection interface for all Device classes and Controllers. 

144 

145 This interface shall be implemented in the Controllers that directly connect to the 

146 hardware, but also in the simulators to guarantee an identical interface as the controllers. 

147 

148 This interface will be implemented in the Proxy classes through the 

149 YAML definitions. Therefore, the YAML files shall define at least 

150 the following commands: `connect`, `disconnect`, `reconnect`, `is_connected`. 

151 """ 

152 

153 def __init__(self): 

154 super().__init__() 

155 

156 def __enter__(self): 

157 self.connect() 

158 return self 

159 

160 def __exit__(self, exc_type, exc_val, exc_tb): 

161 self.disconnect() 

162 

163 @dynamic_interface 

164 def connect(self): 

165 """Connect to the device controller. 

166 

167 Raises: 

168 ConnectionError: when the connection can not be opened. 

169 """ 

170 

171 raise NotImplementedError 

172 

173 @dynamic_interface 

174 def disconnect(self): 

175 """Disconnect from the device controller. 

176 

177 Raises: 

178 ConnectionError: when the connection can not be closed. 

179 """ 

180 raise NotImplementedError 

181 

182 @dynamic_interface 

183 def reconnect(self): 

184 """Reconnect the device controller. 

185 

186 Raises: 

187 ConnectionError: when the device can not be reconnected for some reason. 

188 """ 

189 raise NotImplementedError 

190 

191 @dynamic_interface 

192 def is_connected(self) -> bool: 

193 """Check if the device is connected. 

194 

195 Returns: 

196 True if the device is connected and responds to a command, False otherwise. 

197 """ 

198 raise NotImplementedError 

199 

200 

201class DeviceInterface(DeviceConnectionInterface): 

202 """Generic interface for all device classes.""" 

203 

204 @dynamic_interface 

205 def is_simulator(self) -> bool: 

206 """Checks whether the device is a simulator rather than a real hardware controller. 

207 

208 This can be useful for testing purposes or when doing actual movement simulations. 

209 

210 Returns: 

211 True if the Device is a Simulator; False if the Device is connected to real hardware. 

212 """ 

213 

214 raise NotImplementedError 

215 

216 

217class DeviceTransport: 

218 """ 

219 Base class for the device transport layer. 

220 """ 

221 

222 def write(self, command: str): 

223 """ 

224 Sends a complete command to the device, handle line termination, and write timeouts. 

225 

226 Args: 

227 command: the command to be sent to the instrument. 

228 """ 

229 

230 raise NotImplementedError() 

231 

232 def read(self) -> bytes: 

233 """ 

234 Reads a bytes object back from the instrument and returns it unaltered. 

235 """ 

236 

237 raise NotImplementedError 

238 

239 def read_string(self, encoding="utf-8") -> str: 

240 return self.read().decode(encoding).strip() 

241 

242 def trans(self, command: str) -> bytes: 

243 """ 

244 Send a single command to the device controller and block until a response from the 

245 controller. 

246 

247 Args: 

248 command: is the command to be sent to the instrument 

249 

250 Returns: 

251 Either a string returned by the controller (on success), or an error message (on failure). 

252 

253 Raises: 

254 DeviceConnectionError: when there was an I/O problem during communication with the controller. 

255 

256 DeviceTimeoutError: when there was a timeout in either sending the command or receiving the response. 

257 """ 

258 

259 raise NotImplementedError 

260 

261 def query(self, command: str) -> bytes: 

262 """ 

263 Send a query to the device and wait for the response. 

264 

265 This `query` method is an alias for the `trans` command. For some commands it might be 

266 more intuitive to use the `query` instead of the `trans`action. No need to override this 

267 method as it delegates to `trans`. 

268 

269 Args: 

270 command (str): the query command. 

271 

272 Returns: 

273 The response to the query. 

274 """ 

275 return self.trans(command) 

276 

277 

278class AsyncDeviceTransport: 

279 """ 

280 Base class for the asynchronous device transport layer. 

281 """ 

282 

283 async def write(self, command: str): 

284 """ 

285 Sends a complete command to the device, handle line termination, and write timeouts. 

286 

287 Args: 

288 command: the command to be sent to the instrument. 

289 """ 

290 

291 raise NotImplementedError() 

292 

293 async def read(self) -> bytes: 

294 """ 

295 Reads a bytes object back from the instrument and returns it unaltered. 

296 """ 

297 

298 raise NotImplementedError 

299 

300 async def trans(self, command: str) -> bytes: 

301 """ 

302 Send a single command to the device controller and block until a response from the 

303 controller. 

304 

305 Args: 

306 command: is the command to be sent to the instrument 

307 

308 Returns: 

309 Either a string returned by the controller (on success), or an error message (on failure). 

310 

311 Raises: 

312 DeviceConnectionError: when there was an I/O problem during communication with the controller. 

313 

314 DeviceTimeoutError: when there was a timeout in either sending the command or receiving the response. 

315 """ 

316 

317 raise NotImplementedError 

318 

319 async def query(self, command: str) -> bytes: 

320 """ 

321 Send a query to the device and wait for the response. 

322 

323 This `query` method is an alias for the `trans` command. For some commands it might be 

324 more intuitive to use the `query` instead of the `trans`action. No need to override this 

325 method as it delegates to `trans`. 

326 

327 Args: 

328 command (str): the query command. 

329 

330 Returns: 

331 The response to the query. 

332 """ 

333 return await self.trans(command) 

334 

335 

336class AsyncDeviceConnectionInterface(DeviceConnectionObservable): 

337 """Generic connection interface for all Device classes and Controllers. 

338 

339 This interface shall be implemented in the Controllers that directly connect to the 

340 hardware, but also in the simulators to guarantee an identical interface as the controllers. 

341 

342 This interface will be implemented in the Proxy classes through the 

343 YAML definitions. Therefore, the YAML files shall define at least 

344 the following commands: `connect`, `disconnect`, `reconnect`, `is_connected`. 

345 """ 

346 

347 def __init__(self): 

348 super().__init__() 

349 

350 def __enter__(self): 

351 self.connect() 

352 return self 

353 

354 def __exit__(self, exc_type, exc_val, exc_tb): 

355 self.disconnect() 

356 

357 async def connect(self) -> None: 

358 """Connect to the device controller. 

359 

360 Raises: 

361 ConnectionError: when the connection can not be opened. 

362 """ 

363 

364 raise NotImplementedError 

365 

366 async def disconnect(self) -> None: 

367 """Disconnect from the device controller. 

368 

369 Raises: 

370 ConnectionError: when the connection can not be closed. 

371 """ 

372 raise NotImplementedError 

373 

374 async def reconnect(self): 

375 """Reconnect the device controller. 

376 

377 Raises: 

378 ConnectionError: when the device can not be reconnected for some reason. 

379 """ 

380 raise NotImplementedError 

381 

382 async def is_connected(self) -> bool: 

383 """Check if the device is connected. 

384 

385 Returns: 

386 True if the device is connected and responds to a command, False otherwise. 

387 """ 

388 raise NotImplementedError 

389 

390 

391class AsyncDeviceInterface(AsyncDeviceConnectionInterface): 

392 """Generic interface for all device classes.""" 

393 

394 def is_simulator(self) -> bool: 

395 """Checks whether the device is a simulator rather than a real hardware controller. 

396 

397 This can be useful for testing purposes or when doing actual movement simulations. 

398 

399 Returns: 

400 True if the Device is a Simulator; False if the Device is connected to real hardware. 

401 """ 

402 

403 raise NotImplementedError 

404 

405 

406class DeviceFactoryInterface: 

407 """ 

408 Base class for creating a device factory class to access devices. 

409 

410 This interface defines one interface method that shall be implemented by the Factory: 

411 ```python 

412 create(device_name: str, *, device_id: str, **_ignored) 

413 ``` 

414 """ 

415 

416 def create(self, device_name: str, *, device_id: str, **_ignored): 

417 """ 

418 Create and return a device class that implements the expected device interface. 

419 The `device_name` and `device_id` can be useful for identifying the specific device. 

420 

421 Additional keyword arguments can be passed to the device factory in order to forward 

422 them to the device constructor, but they will usually be ignored. 

423 """ 

424 ...