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

1"""

2 This module defines the generic interfaces to connect devices.

3"""

4

5 from enum import IntEnum

6

7 from egse.decorators import dynamic_interface

8 from egse.exceptions import Error

9

10

11 class 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

25 class 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

41 class 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

57 class 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

69 class 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

81 class 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

93 class 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

113 class 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

142 class 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

201 class 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

217 class 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

278 class 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

336 class 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

391 class 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

406 class 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 ...