Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\dev\supercube\base.py : 0%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# Copyright (c) 2019-2020 ETH Zurich, SIS ID and HVL D-ITET
2#
3"""
4Base classes for the Supercube device.
5"""
7import logging
8from collections import deque
9from datetime import datetime
10from itertools import cycle
11from time import sleep
12from typing import List, Sequence
14from opcua import Node
16from . import constants
17from ..base import SingleCommDevice
18from ..utils import Poller
19from ...comm import (
20 OpcUaCommunication,
21 OpcUaCommunicationConfig,
22 OpcUaSubHandler,
23)
24from ...configuration import configdataclass
25from ...utils.typing import Number
28class SupercubeEarthingStickOperationError(Exception):
29 pass
32class SupercubeSubscriptionHandler(OpcUaSubHandler):
33 """
34 OPC Subscription handler for datachange events and normal events specifically
35 implemented for the Supercube devices.
36 """
38 def datachange_notification(self, node: Node, val, data):
39 """
40 In addition to the standard operation (debug logging entry of the datachange),
41 alarms are logged at INFO level using the alarm text.
43 :param node: the node object that triggered the datachange event
44 :param val: the new value
45 :param data:
46 """
48 super().datachange_notification(node, val, data)
50 # assume an alarm datachange
51 try:
52 alarm_number = constants.Alarms(node.nodeid.Identifier).number
53 alarm_text = constants.AlarmText.get(alarm_number)
54 alarm_log_prefix = "Coming" if val else "Going"
55 logging.getLogger(__name__).info(
56 f"{alarm_log_prefix} Alarm {alarm_number}: {str(alarm_text)}"
57 )
58 return
59 except ValueError:
60 # not any of Alarms node IDs
61 pass
63 id_ = node.nodeid.Identifier
65 # assume a status datachange
66 if id_ == str(constants.Safety.status):
67 new_status = str(constants.SafetyStatus(val))
68 logging.getLogger(__name__).info(f"Safety: {new_status}")
69 return
71 # assume an earthing stick status datachange
72 if id_ in constants.EarthingStick.statuses():
73 new_status = str(constants.EarthingStickStatus(val))
74 logging.getLogger(__name__).info(
75 f"Earthing {constants.EarthingStick(id_).number}: {new_status}"
76 )
77 return
80@configdataclass
81class SupercubeConfiguration:
82 """
83 Configuration dataclass for the Supercube devices.
84 """
86 #: Namespace of the OPC variables, typically this is 3 (coming from Siemens)
87 namespace_index: int = 3
89 polling_delay_sec: Number = 5.0
90 polling_interval_sec: Number = 1.0
92 def clean_values(self):
93 if self.namespace_index < 0:
94 raise ValueError(
95 "Index of the OPC variables namespace needs to be a positive integer."
96 )
97 if self.polling_interval_sec <= 0:
98 raise ValueError("Polling interval needs to be positive.")
99 if self.polling_delay_sec < 0:
100 raise ValueError("Polling delay needs to be not negative.")
103@configdataclass
104class SupercubeOpcUaCommunicationConfig(OpcUaCommunicationConfig):
105 """
106 Communication protocol configuration for OPC UA, specifications for the Supercube
107 devices.
108 """
110 #: Subscription handler for data change events
111 sub_handler: OpcUaSubHandler = SupercubeSubscriptionHandler()
114class SupercubeOpcUaCommunication(OpcUaCommunication):
115 """
116 Communication protocol specification for Supercube devices.
117 """
119 @staticmethod
120 def config_cls():
121 return SupercubeOpcUaCommunicationConfig
124class SupercubeBase(SingleCommDevice):
125 """
126 Base class for Supercube variants.
127 """
129 def __init__(self, com, dev_config=None):
130 """
131 Constructor for Supercube base class.
133 :param com: the communication protocol or its configuration
134 :param dev_config: the device configuration
135 """
137 super().__init__(com, dev_config)
139 self.status_poller = Poller(
140 self._spoll_handler,
141 polling_delay_sec=self.config.polling_delay_sec,
142 polling_interval_sec=self.config.polling_interval_sec,
143 )
144 self.toggle = cycle([False, True])
145 self.logger = logging.getLogger(__name__)
146 self.message_len = len(constants.MessageBoard)
147 self.status_board = [""] * self.message_len
148 self.message_board = deque([""] * self.message_len, maxlen=self.message_len)
150 @staticmethod
151 def default_com_cls():
152 return SupercubeOpcUaCommunication
154 def start(self) -> None:
155 """
156 Starts the device. Sets the root node for all OPC read and write commands to
157 the Siemens PLC object node which holds all our relevant objects and variables.
158 """
160 self.logger.info("Starting Supercube Base device")
161 super().start()
163 self.logger.debug("Add monitoring nodes")
164 self.com.init_monitored_nodes(
165 map( # type: ignore
166 str, constants.GeneralSockets
167 ),
168 self.config.namespace_index,
169 )
170 self.com.init_monitored_nodes(
171 map( # type: ignore
172 str, constants.GeneralSupport
173 ),
174 self.config.namespace_index,
175 )
176 self.com.init_monitored_nodes(
177 map( # type: ignore
178 str, constants.Safety
179 ),
180 self.config.namespace_index,
181 )
182 self.com.init_monitored_nodes(
183 str(constants.Errors.message), self.config.namespace_index
184 )
185 self.com.init_monitored_nodes(
186 str(constants.Errors.warning), self.config.namespace_index
187 )
188 self.com.init_monitored_nodes(
189 str(constants.Errors.stop), self.config.namespace_index
190 )
191 self.com.init_monitored_nodes(
192 map(str, constants.Alarms), self.config.namespace_index
193 )
194 self.com.init_monitored_nodes(
195 map( # type: ignore
196 str, constants.EarthingStick
197 ),
198 self.config.namespace_index,
199 )
201 self.set_remote_control(True)
202 self.logger.debug("Finished starting")
204 def stop(self) -> None:
205 """
206 Stop the Supercube device. Deactivates the remote control and closes the
207 communication protocol.
208 """
209 try:
210 self.set_remote_control(False)
211 finally:
212 super().stop()
214 def _spoll_handler(self) -> None:
215 """
216 Supercube poller handler; change one byte on a SuperCube.
217 """
218 self.write(constants.OpcControl.live, next(self.toggle))
220 @staticmethod
221 def config_cls():
222 return SupercubeConfiguration
224 def read(self, node_id: str):
225 """
226 Local wrapper for the OPC UA communication protocol read method.
228 :param node_id: the id of the node to read.
229 :return: the value of the variable
230 """
232 self.logger.debug(f"Read from node ID {node_id} ...")
233 result = self.com.read(str(node_id), self.config.namespace_index)
234 self.logger.debug(f"Read from node ID {node_id}: {result}")
235 return result
237 def write(self, node_id, value) -> None:
238 """
239 Local wrapper for the OPC UA communication protocol write method.
241 :param node_id: the id of the node to read
242 :param value: the value to write to the variable
243 """
244 self.logger.debug(f"Write to node ID {node_id}: {value}")
245 self.com.write(str(node_id), self.config.namespace_index, value)
247 def set_remote_control(self, state: bool) -> None:
248 """
249 Enable or disable remote control for the Supercube. This will effectively
250 display a message on the touchscreen HMI.
252 :param state: desired remote control state
253 """
254 can_write = False
255 try:
256 self.write(constants.OpcControl.active, bool(state))
257 can_write = True
258 finally:
259 if state:
260 if not can_write:
261 self.logger.warning("Remote control cannot be enabled")
262 else:
263 was_not_polling = self.status_poller.start_polling()
264 if not was_not_polling:
265 self.logger.warning("Remote control already enabled")
266 else:
267 was_polling = self.status_poller.stop_polling()
268 self.status_poller.wait_for_polling_result()
269 if not was_polling:
270 self.logger.warning("Remote control already disabled")
272 def get_support_input(self, port: int, contact: int) -> bool:
273 """
274 Get the state of a support socket input.
276 :param port: is the socket number (1..6)
277 :param contact: is the contact on the socket (1..2)
278 :return: digital input read state
279 :raises ValueError: when port or contact number is not valid
280 """
282 return bool(self.read(constants.GeneralSupport.input(port, contact)))
284 def get_support_output(self, port: int, contact: int) -> bool:
285 """
286 Get the state of a support socket output.
288 :param port: is the socket number (1..6)
289 :param contact: is the contact on the socket (1..2)
290 :return: digital output read state
291 :raises ValueError: when port or contact number is not valid
292 """
294 return bool(self.read(constants.GeneralSupport.output(port, contact)))
296 def set_support_output(self, port: int, contact: int, state: bool) -> None:
297 """
298 Set the state of a support output socket.
300 :param port: is the socket number (1..6)
301 :param contact: is the contact on the socket (1..2)
302 :param state: is the desired state of the support output
303 :raises ValueError: when port or contact number is not valid
304 """
306 self.write(constants.GeneralSupport.output(port, contact), bool(state))
308 def set_support_output_impulse(
309 self, port: int, contact: int, duration: float = 0.2, pos_pulse: bool = True
310 ) -> None:
311 """
312 Issue an impulse of a certain duration on a support output contact. The polarity
313 of the pulse (On-wait-Off or Off-wait-On) is specified by the pos_pulse
314 argument.
316 This function is blocking.
318 :param port: is the socket number (1..6)
319 :param contact: is the contact on the socket (1..2)
320 :param duration: is the length of the impulse in seconds
321 :param pos_pulse: is True, if the pulse shall be HIGH, False if it shall be LOW
322 :raises ValueError: when port or contact number is not valid
323 """
325 self.set_support_output(port, contact, pos_pulse)
326 sleep(duration)
327 self.set_support_output(port, contact, not pos_pulse)
329 def get_t13_socket(self, port: int) -> bool:
330 """
331 Read the state of a SEV T13 power socket.
333 :param port: is the socket number, one of `constants.T13_SOCKET_PORTS`
334 :return: on-state of the power socket
335 :raises ValueError: when port is not valid
336 """
338 if port not in constants.T13_SOCKET_PORTS:
339 raise ValueError(f"port not in {constants.T13_SOCKET_PORTS}: {port}")
341 return bool(self.read(getattr(constants.GeneralSockets, f"t13_{port}")))
343 def set_t13_socket(self, port: int, state: bool) -> None:
344 """
345 Set the state of a SEV T13 power socket.
347 :param port: is the socket number, one of `constants.T13_SOCKET_PORTS`
348 :param state: is the desired on-state of the socket
349 :raises ValueError: when port is not valid or state is not of type bool
350 """
352 if not isinstance(state, bool):
353 raise ValueError(f"state is not <bool>: {state}")
355 if port not in constants.T13_SOCKET_PORTS:
356 raise ValueError(f"port not in {constants.T13_SOCKET_PORTS}: {port}")
358 self.write(getattr(constants.GeneralSockets, f"t13_{port}"), state)
360 def get_cee16_socket(self) -> bool:
361 """
362 Read the on-state of the IEC CEE16 three-phase power socket.
364 :return: the on-state of the CEE16 power socket
365 """
367 return bool(self.read(constants.GeneralSockets.cee16))
369 def set_cee16_socket(self, state: bool) -> None:
370 """
371 Switch the IEC CEE16 three-phase power socket on or off.
373 :param state: desired on-state of the power socket
374 :raises ValueError: if state is not of type bool
375 """
377 if not isinstance(state, bool):
378 raise ValueError(f"state is not <bool>: {state}")
380 self.write(constants.GeneralSockets.cee16, state)
382 def get_status(self) -> constants.SafetyStatus:
383 """
384 Get the safety circuit status of the Supercube.
385 :return: the safety status of the supercube's state machine.
386 """
388 return constants.SafetyStatus(self.read(constants.Safety.status))
390 def ready(self, state: bool) -> None:
391 """
392 Set ready state. Ready means locket safety circuit, red lamps, but high voltage
393 still off.
395 :param state: set ready state
396 """
398 self.write(constants.Safety.switch_to_ready, state)
400 def operate(self, state: bool) -> None:
401 """
402 Set operate state. If the state is RedReady, this will turn on the high
403 voltage and close the safety switches.
405 :param state: set operate state
406 """
408 self.write(constants.Safety.switch_to_operate, state)
410 def get_measurement_ratio(self, channel: int) -> float:
411 """
412 Get the set measurement ratio of an AC/DC analog input channel. Every input
413 channel has a divider ratio assigned during setup of the Supercube system.
414 This ratio can be read out.
416 :param channel: number of the input channel (1..4)
417 :return: the ratio
418 :raises ValueError: when channel is not valid
419 """
421 return float(self.read(constants.MeasurementsDividerRatio.input(channel)))
423 def get_measurement_voltage(self, channel: int) -> float:
424 """
425 Get the measured voltage of an analog input channel. The voltage read out
426 here is already scaled by the configured divider ratio.
428 :param channel: number of the input channel (1..4)
429 :return: measured voltage
430 :raises ValueError: when channel is not valid
431 """
433 return float(self.read(constants.MeasurementsScaledInput.input(channel)))
435 def get_earthing_stick_status(self, number: int) -> constants.EarthingStickStatus:
436 """
437 Get the status of an earthing stick, whether it is closed, open or undefined
438 (moving).
440 :param number: number of the earthing stick (1..6)
441 :return: earthing stick status
442 :raises ValueError: when earthing stick number is not valid
443 """
445 return constants.EarthingStickStatus(
446 self.read(constants.EarthingStick.status(number))
447 )
449 def get_earthing_stick_operating_status(
450 self, number: int
451 ) -> constants.EarthingStickOperatingStatus:
452 """
453 Get the operating status of an earthing stick.
455 :param number: number of the earthing stick (1..6)
456 :return: earthing stick operating status (auto == 0, manual == 1)
457 :raises ValueError: when earthing stick number is not valid
458 """
459 return constants.EarthingStickOperatingStatus(
460 self.read(constants.EarthingStick.operating_status(number))
461 )
463 def get_earthing_stick_manual(
464 self, number: int
465 ) -> constants.EarthingStickOperation:
466 """
467 Get the manual status of an earthing stick. If an earthing stick is set to
468 manual, it is closed even if the system is in states RedReady or RedOperate.
470 :param number: number of the earthing stick (1..6)
471 :return: operation of the earthing stick in a manual operating mode (open == 0,
472 close == 1)
473 :raises ValueError: when earthing stick number is not valid
474 """
475 return constants.EarthingStickOperation(
476 int(self.read(constants.EarthingStick.manual(number)))
477 )
479 def operate_earthing_stick(
480 self, number: int, operation: constants.EarthingStickOperation
481 ) -> None:
482 """
483 Operation of an earthing stick, which is set to manual operation. If an earthing
484 stick is set to manual, it stays closed even if the system is in states
485 RedReady or RedOperate.
487 :param number: number of the earthing stick (1..6)
488 :param operation: earthing stick manual status (close or open)
489 :raises SupercubeEarthingStickOperationError: when operating status of given
490 number's earthing stick is not manual
491 """
493 if (
494 self.get_earthing_stick_operating_status(number)
495 == constants.EarthingStickOperatingStatus.manual
496 ):
497 self.write(constants.EarthingStick.manual(number), bool(operation.value))
498 else:
499 raise SupercubeEarthingStickOperationError
501 def quit_error(self) -> None:
502 """
503 Quits errors that are active on the Supercube.
504 """
506 self.write(constants.Errors.quit, True)
507 sleep(0.1)
508 self.write(constants.Errors.quit, False)
510 def get_door_status(self, door: int) -> constants.DoorStatus:
511 """
512 Get the status of a safety fence door. See :class:`constants.DoorStatus` for
513 possible returned door statuses.
515 :param door: the door number (1..3)
516 :return: the door status
517 """
519 return constants.DoorStatus(self.read(constants.Door.status(door)))
521 def get_earthing_rod_status(self, earthing_rod: int) -> constants.EarthingRodStatus:
522 """
523 Get the status of a earthing rod. See :class:`constants.EarthingRodStatus` for
524 possible returned earthing rod statuses.
526 :param earthing_rod: the earthing rod number (1..3)
527 :return: the earthing rod status
528 """
529 value = self.read(constants.EarthingRod.status(earthing_rod))
530 return constants.EarthingRodStatus(value)
532 def set_status_board(
533 self, msgs: List[str],
534 pos: List[int] = None,
535 clear_board: bool = True,
536 display_board: bool = True,
537 ) -> None:
538 """
539 Sets and displays a status board. The messages and the position of the message
540 can be defined.
542 :param msgs: list of strings
543 :param pos: list of integers [0...14]
544 :param clear_board: clear unspecified lines if `True` (default), keep otherwise
545 :param display_board: display new status board if `True` (default)
546 :raises ValueError: if there are too many messages or the positions indices are
547 invalid.
548 """
549 # validate inputs
550 if len(msgs) > self.message_len:
551 raise ValueError(
552 f"Too many message: {len(msgs)} given, max. {self.message_len} allowed."
553 )
554 if pos and not all(0 < p < self.message_len for p in pos):
555 raise ValueError(f"Messages positions out of 0...{self.message_len} range")
557 if clear_board:
558 self.status_board = [""] * self.message_len
560 # update status board
561 if not pos:
562 pos = list(range(len(msgs)))
563 for num, msg in zip(pos, msgs):
564 self.status_board[num] = msg
565 if display_board:
566 self.display_status_board()
568 def display_status_board(self) -> None:
569 """
570 Display status board.
571 """
572 return self._display_messages(self.status_board)
574 def set_message_board(self, msgs: List[str], display_board: bool = True) -> None:
575 """
576 Fills messages into message board that display that 15 newest messages with
577 a timestamp.
579 :param msgs: list of strings
580 :param display_board: display 15 newest messages if `True` (default)
581 :raises ValueError: if there are too many messages or the positions indices are
582 invalid.
583 """
584 # validate inputs
585 if len(msgs) > self.message_len:
586 raise ValueError(
587 f"Too many message: {len(msgs)} given, max. {self.message_len} allowed."
588 )
590 timestamp = datetime.now().time().strftime("%H:%M:%S")
591 # append messages in the same order as given, not reversed
592 self.message_board.extendleft(
593 f"{timestamp}: {msg}" for msg in reversed(msgs)
594 )
596 if display_board:
597 self.display_message_board()
599 def display_message_board(self) -> None:
600 """
601 Display 15 newest messages
602 """
603 return self._display_messages(self.message_board)
605 def _display_messages(self, messages: Sequence[str]) -> None:
606 """
607 Display given messages on message board
609 :param messages: sequence of messages to display
610 """
611 # Note: cannot zip(constants.MessageBoard, messages) as enum instances are
612 # sorted by name, hence after after `line_1` comes `line_10`, not `line_2`
613 for n, msg in enumerate(messages):
614 line = constants.MessageBoard.line(n+1)
615 self.write(line, msg)