def __init__( self, parent: QWidget, on_connection_request: ConnectionRequestCallback, on_disconnection_request: DisconnectionRequestCallback, commander: Commander, ): super(MainWidget, self).__init__(parent) self._resize_event = Event() self._device_management_widget = DeviceManagementWidget( self, on_connection_request=on_connection_request, on_disconnection_request=on_disconnection_request, ) self._telega_control_widget = TelegaControlWidget(self, commander) self.addTab(self._device_management_widget, get_icon("connector"), "Device management") self.addTab(self._telega_control_widget, get_icon("wagon"), "Telega control panel") self.setCurrentWidget(self._device_management_widget)
def __init__( self, parent: QWidget, minimum: float = 0.0, maximum: float = 100.0, step: float = 1.0, slider_orientation: SliderOrientation = SliderOrientation.VERTICAL ): self._events_suppression_depth = 0 # Instantiating the widgets self._box = QDoubleSpinBox(parent) self._sld = QSlider(int(slider_orientation), parent) self._sld.setTickPosition( QSlider.TicksBothSides) # Perhaps expose this via API later # This stuff breaks if I remove lambdas, no clue why, investigate later self._box.valueChanged[float].connect( lambda v: self._on_box_changed(v)) self._sld.valueChanged.connect(lambda v: self._on_sld_changed(v)) # Initializing the parameters with self._with_events_suppressed(): self.set_range(minimum, maximum) self.step = step # Initializing the API self._value_change_event = Event()
def __init__( self, name: str, value: StrictValueTypeAnnotation, default_value: typing.Optional[StrictValueTypeAnnotation], min_value: typing.Optional[StrictValueTypeAnnotation], max_value: typing.Optional[StrictValueTypeAnnotation], type_id: ValueType, flags: Flags, update_timestamp_device_time: Decimal, set_get_callback: SetGetCallback, update_timestamp_monotonic: float = None, ): self._name = str(name) self._cached_value = value self._default_value = default_value self._min_value = min_value self._max_value = max_value self._type_id = ValueType(type_id) self._update_ts_device_time = Decimal(update_timestamp_device_time) self._update_ts_monotonic = float(update_timestamp_monotonic or time.monotonic()) self._flags = flags self._set_get_callback = set_get_callback self._update_event = Event()
class MainWidget(QTabWidget): def __init__( self, parent: QWidget, on_connection_request: ConnectionRequestCallback, on_disconnection_request: DisconnectionRequestCallback, commander: Commander, ): super(MainWidget, self).__init__(parent) self._resize_event = Event() self._device_management_widget = DeviceManagementWidget( self, on_connection_request=on_connection_request, on_disconnection_request=on_disconnection_request, ) self._telega_control_widget = TelegaControlWidget(self, commander) self.addTab(self._device_management_widget, get_icon("connector"), "Device management") self.addTab(self._telega_control_widget, get_icon("wagon"), "Telega control panel") self.setCurrentWidget(self._device_management_widget) @property def resize_event(self) -> Event: return self._resize_event def on_connection_established(self, device_info: BasicDeviceInfo): self._telega_control_widget.on_connection_established() self.setCurrentWidget(self._telega_control_widget) def on_connection_loss(self, reason: str): self.setCurrentWidget(self._device_management_widget) self._device_management_widget.on_connection_loss(reason) self._telega_control_widget.on_connection_loss() def on_connection_initialization_progress_report(self, stage_description: str, progress: float): self.setCurrentWidget(self._device_management_widget) self._device_management_widget.on_connection_initialization_progress_report( stage_description, progress) def on_general_status_update(self, timestamp: float, status: GeneralStatusView): self._telega_control_widget.on_general_status_update(timestamp, status) def resizeEvent(self, event: QResizeEvent): super(MainWidget, self).resizeEvent(event) self._resize_event.emit()
def __init__(self, event_loop: asyncio.AbstractEventLoop): self._event_loop = event_loop self._conn: Connection = None async def send_command(msg: Message): self._ensure_connected() await self._conn.send(msg) self._commander = Commander(send_command) self._evt_device_status_update = Event() self._evt_log_line = Event() self._evt_connection_status_change = Event() self._evt_consolidated_register_update = Event()
def __init__(self, parent: QWidget, title: typing.Optional[str] = None, icon_name: typing.Optional[str] = None): super(QDockWidget, self).__init__(parent) self.setAttribute(Qt.WA_DeleteOnClose) # This is required to stop background timers! if title: self.setWindowTitle(title) if icon_name: self.set_icon(icon_name) self._close_event = Event() self._resize_event = Event()
def __init__(self, parent_window: QMainWindow): self._parent_window: QMainWindow = parent_window self._children: typing.List[ToolWindow] = [] self._menu: QMenu = None self._arrangement_rules: typing.List[_ArrangementRule] = [] self._title_to_icon_mapping: typing.Dict[str, QIcon] = {} self._tool_window_resize_event = Event() self._new_tool_window_event = Event() self._tool_window_removed_event = Event() self._parent_window.tabifiedDockWidgetActivated.connect( self._reiconize) # Set up the appearance self._parent_window.setTabPosition(Qt.TopDockWidgetArea, QTabWidget.North) self._parent_window.setTabPosition(Qt.BottomDockWidgetArea, QTabWidget.South) self._parent_window.setTabPosition(Qt.LeftDockWidgetArea, QTabWidget.South) self._parent_window.setTabPosition(Qt.RightDockWidgetArea, QTabWidget.South) # Now, most screens are wide but not very tall; we need to optimize the layout for that # More info (this is for Qt4 but works for Qt5 as well): https://doc.qt.io/archives/4.6/qt4-mainwindow.html self._parent_window.setCorner(Qt.TopLeftCorner, Qt.LeftDockWidgetArea) self._parent_window.setCorner(Qt.BottomLeftCorner, Qt.LeftDockWidgetArea) self._parent_window.setCorner(Qt.TopRightCorner, Qt.RightDockWidgetArea) self._parent_window.setCorner(Qt.BottomRightCorner, Qt.RightDockWidgetArea) # http://doc.qt.io/qt-5/qmainwindow.html#DockOption-enum dock_options = (self._parent_window.AnimatedDocks | self._parent_window.AllowTabbedDocks) if not is_small_screen(): dock_options |= (self._parent_window.AllowNestedDocks ) # This won't work well on small screens self._parent_window.setDockOptions(dock_options)
class ToolWindowManager: # noinspection PyUnresolvedReferences def __init__(self, parent_window: QMainWindow): self._parent_window: QMainWindow = parent_window self._children: typing.List[ToolWindow] = [] self._menu: QMenu = None self._arrangement_rules: typing.List[_ArrangementRule] = [] self._title_to_icon_mapping: typing.Dict[str, QIcon] = {} self._tool_window_resize_event = Event() self._new_tool_window_event = Event() self._tool_window_removed_event = Event() self._parent_window.tabifiedDockWidgetActivated.connect( self._reiconize) # Set up the appearance self._parent_window.setTabPosition(Qt.TopDockWidgetArea, QTabWidget.North) self._parent_window.setTabPosition(Qt.BottomDockWidgetArea, QTabWidget.South) self._parent_window.setTabPosition(Qt.LeftDockWidgetArea, QTabWidget.South) self._parent_window.setTabPosition(Qt.RightDockWidgetArea, QTabWidget.South) # Now, most screens are wide but not very tall; we need to optimize the layout for that # More info (this is for Qt4 but works for Qt5 as well): https://doc.qt.io/archives/4.6/qt4-mainwindow.html self._parent_window.setCorner(Qt.TopLeftCorner, Qt.LeftDockWidgetArea) self._parent_window.setCorner(Qt.BottomLeftCorner, Qt.LeftDockWidgetArea) self._parent_window.setCorner(Qt.TopRightCorner, Qt.RightDockWidgetArea) self._parent_window.setCorner(Qt.BottomRightCorner, Qt.RightDockWidgetArea) # http://doc.qt.io/qt-5/qmainwindow.html#DockOption-enum dock_options = self._parent_window.AnimatedDocks | self._parent_window.AllowTabbedDocks if not is_small_screen(): dock_options |= self._parent_window.AllowNestedDocks # This won't work well on small screens self._parent_window.setDockOptions(dock_options) @property def tool_window_resize_event(self) -> Event: """Passed arguments: the widget whose tool window got resized""" return self._tool_window_resize_event @property def new_tool_window_event(self) -> Event: """Passed arguments: the affected tool window""" return self._new_tool_window_event @property def tool_window_removed_event(self) -> Event: """Passed arguments: the affected tool window""" return self._tool_window_removed_event # noinspection PyUnresolvedReferences def register(self, factory: typing.Union[typing.Type[QWidget], typing.Callable[[ToolWindow], QWidget]], title: str, icon_name: typing.Optional[str] = None, allow_multiple_instances: bool = False, shown_by_default: bool = False): """ Adds the specified tool WIDGET (not window) to the set of known tools. If requested, it can be instantiated automatically at the time of application startup. The class will automatically register the menu item and do all of the other boring boilerplate stuff. """ if self._menu is None: self._menu = self._parent_window.menuBar().addMenu('&Tools') def spawn(): def terminate(): self._children.remove(tw) action.setEnabled(True) self._tool_window_removed_event.emit(tw) # noinspection PyBroadException try: # Instantiating the tool window and set up its widget using the client-provided factory tw = ToolWindow(self._parent_window, title=title, icon_name=icon_name) tw.widget = factory(tw) # Set up the tool window self._children.append(tw) tw.close_event.connect(terminate) tw.resize_event.connect( lambda: self._on_tool_window_resize(tw)) self._allocate(tw) # Below we're making sure that the newly added tool window ends up on top # https://stackoverflow.com/questions/1290882/focusing-on-a-tabified-qdockwidget-in-pyqt tw.show() tw.raise_() if not allow_multiple_instances: action.setEnabled(False) self._new_tool_window_event.emit(tw) except Exception: _logger.exception( f'Could not spawn tool window {title!r} with icon {icon_name!r}' ) else: _logger.info( f'Spawned tool window {tw!r} {title!r} with icon {icon_name!r}' ) icon = get_icon(icon_name) # FIXME: This is not cool - a title collision will mess up our icons! self._title_to_icon_mapping[title] = icon action = QAction(icon, title, self._parent_window) action.triggered.connect(spawn) self._menu.addAction(action) if shown_by_default: spawn() def add_arrangement_rule(self, apply_to: typing.Iterable[typing.Type[QWidget]], group_when: ToolWindowGroupingCondition, location: ToolWindowLocation): """ :param apply_to: :param group_when: Grouping policy: NEVER - do not group unless the user did that manually SAME_LOCATION - group only if the grouped widgets are at the same location ALWAYS - group always, regardless of the location :param location: Default placement in the main window """ self._arrangement_rules.append( _ArrangementRule(apply_to=list(apply_to), group_when=group_when, location=location)) def select_widgets( self, widget_type: typing.Type[_WidgetTypeVar] = QWidget, current_location: typing.Optional[ToolWindowLocation] = None ) -> typing.List[_WidgetTypeVar]: """ Returns a list of references to the root widgets of all existing tool windows which are instances of the specified type. This can be used to broadcast events and such. Specify the type as QWidget to accept all widgets. """ out: typing.List[_WidgetTypeVar] = [] for win in self._children: if not isinstance(win.widget, widget_type): continue if current_location is not None: if self._parent_window.dockWidgetArea(win) != int( current_location): continue out.append(win.widget) return out def _select_tool_windows( self, widget_type: typing.Type[QWidget]) -> typing.List[ToolWindow]: return [ win for win in self._children if isinstance(win.widget, widget_type) ] def _select_applicable_arrangement_rules(self, widget_type: typing.Type[QWidget]) -> \ typing.List['_ArrangementRule']: return [ copy.deepcopy(ar) for ar in self._arrangement_rules if widget_type in ar.apply_to ] def _allocate(self, what: ToolWindow): widget_type = type(what.widget) rules = self._select_applicable_arrangement_rules(widget_type) if not rules: raise ValueError( f'Arrangement rules for widget of type {widget_type} could not be found' ) self._parent_window.addDockWidget(int(rules[0].location), what) # Oblaka, belogrivye loshadki... for ar in rules: matching_windows: typing.List[ToolWindow] = [] for applicable in ar.apply_to: if applicable is not widget_type: matching_windows += self._select_tool_windows(applicable) _logger.info( f'Existing tool windows matching the rule {ar} against {widget_type}: {matching_windows}' ) if not matching_windows: continue if ar.group_when == ToolWindowGroupingCondition.NEVER: continue if ar.group_when == ToolWindowGroupingCondition.SAME_LOCATION: tabify_with = None for mw in matching_windows: if int(ar.location) == self._parent_window.dockWidgetArea( mw): tabify_with = mw break if tabify_with is None: continue else: tabify_with = matching_windows[0] # Observe that the order of arguments matters here. The second widget will end up on top. # We always show the freshly added widget on top. self._parent_window.tabifyDockWidget(tabify_with, what) break self._reiconize() def _reiconize(self, *_): # https://stackoverflow.com/questions/46613165/qt-tab-icon-when-qdockwidget-becomes-docked # In order to reduce the probability of hitting a false positive, we query only the direct children of the # main window. Conveniently, the tab bars in the dock areas are direct descendants of the main window. # It is assumed that this can never be the case with other widgets, since tab bars are usually nested into # other widgets. to_a_bar = self._parent_window.findChildren(QTabBar, '', Qt.FindDirectChildrenOnly) for tab_walks in to_a_bar: # ha ha for index in range(tab_walks.count()): title = tab_walks.tabText(index) try: icon = self._title_to_icon_mapping[title] except KeyError: continue tab_walks.setTabIcon(index, icon) def _on_tool_window_resize(self, instance: ToolWindow): self._tool_window_resize_event.emit(instance.widget)
class SpinboxLinkedWithSlider: """ A simple class that contains a pair of linked QDoubleSpinBox and QSlider. The class does all of the tricky management inside in order to ensure that both widgets are perfectly in sync at all times. There is a number of complicated corner cases that one might stumble upon when making ad-hoc implementations of the same logic, so it makes sense to factor it out once into a well-tested class. Note that this is not a widget. The user will have to allocate the two linked widgets on their own. They are accessible via the respective properties. Note that the user must NEVER attempt to modify the values in the widgets manually, as that will break the class' own behavior. This is why the widget accessor properties are annotated as the most generic widget type - QWidget. I wrote the comment below in a different piece of code trying to document the dangers of flawed synchronization between a spin box and a slider. Ultimately, that was why I decided to separate that logic out into a separate class. The comment is provided here verbatim for historical reasons: The on-changed signal MUST be disconnected, otherwise we may end up emitting erroneous setpoints while the values are being updated. Another problem with the signals is that they go through the slider, if it is enabled, which breaks the value configured in the spin box. This is how it's happening (I had to look into the Qt's sources in order to find this out): setRange() updates the limits. If the currently set value is outside of the limits, it is updated. Assuming that the old value doesn't fit the new limits, setRange() invokes setValue(), which, in turn, invokes the on-change event handler in this class. The on-change event handler then moves the slider, in order to keep it in sync with the value. If the value exceeds the range of the slider, the slider will silently clip it, and then set the clipped value back to the spinbox from its event handler. A catastrophe! We lost the proper value and ended up with a clipped one. This is how it happens, if we were to print() out the relevant values from handlers: CONFIGURING RANGE AND VALUE... SPINBOX CHANGED TO 678.52 <-- this is the correct value SLIDER MOVED TO 100% <-- doesn't fit into the slider's range, so it gets clipped SPINBOX CHANGED TO 100.0 <-- the clipped value fed back (and sent to the device!) RANGE AND VALUE CONFIGURED So we disconnect the signal before changing stuff, and then connect the signal back. """ class SliderOrientation(enum.IntEnum): HORIZONTAL = Qt.Horizontal VERTICAL = Qt.Vertical # noinspection PyUnresolvedReferences def __init__( self, parent: QWidget, minimum: float = 0.0, maximum: float = 100.0, step: float = 1.0, slider_orientation: SliderOrientation = SliderOrientation.VERTICAL ): self._events_suppression_depth = 0 # Instantiating the widgets self._box = QDoubleSpinBox(parent) self._sld = QSlider(int(slider_orientation), parent) self._sld.setTickPosition( QSlider.TicksBothSides) # Perhaps expose this via API later # This stuff breaks if I remove lambdas, no clue why, investigate later self._box.valueChanged[float].connect( lambda v: self._on_box_changed(v)) self._sld.valueChanged.connect(lambda v: self._on_sld_changed(v)) # Initializing the parameters with self._with_events_suppressed(): self.set_range(minimum, maximum) self.step = step # Initializing the API self._value_change_event = Event() @property def value_change_event(self) -> Event: return self._value_change_event @property def spinbox(self) -> QWidget: """ Annotated as QWidget in order to prevent direct access to the critical functionality of the widgets, as that may break the inner logic of the class. This property should only be used for layout purposes. """ return self._box @property def slider(self) -> QWidget: """ Annotated as QWidget in order to prevent direct access to the critical functionality of the widgets, as that may break the inner logic of the class. This property should only be used for layout purposes. """ return self._sld @property def minimum(self) -> float: return self._box.minimum() @minimum.setter def minimum(self, value: float): self._box.setMinimum(value) with self._with_events_suppressed(): self._sld.setMinimum(self._value_to_int(value)) _logger.debug('New minimum: %r %r', value, self._value_to_int(value)) @property def maximum(self) -> float: return self._box.maximum() @maximum.setter def maximum(self, value: float): self._box.setMaximum(value) with self._with_events_suppressed(): self._sld.setMaximum(self._value_to_int(value)) self._refresh_invariants() _logger.debug('New maximum: %r %r', value, self._value_to_int(value)) @property def step(self) -> float: return self._box.singleStep() @step.setter def step(self, value: float): if not (value > 0): raise ValueError(f'Step must be positive, got {value!r}') self._box.setSingleStep(value) with self._with_events_suppressed(): self._sld.setMinimum(self._value_to_int(self.minimum)) self._sld.setMaximum(self._value_to_int(self.maximum)) self._sld.setValue(self._value_to_int(self.value)) self._refresh_invariants() _logger.debug('New step: %r; resulting range of the slider: [%r, %r]', value, self._sld.minimum(), self._sld.maximum()) @property def value(self) -> float: return self._box.value() @value.setter def value(self, value: float): self._box.setValue(value) with self._with_events_suppressed(): self._sld.setValue(self._value_to_int(value)) @property def num_decimals(self) -> int: return self._box.decimals() @num_decimals.setter def num_decimals(self, value: int): self._box.setDecimals(value) @property def tool_tip(self) -> str: return self._box.toolTip() @tool_tip.setter def tool_tip(self, value: str): self._box.setToolTip(value) self._sld.setToolTip(value) @property def status_tip(self) -> str: return self._box.statusTip() @status_tip.setter def status_tip(self, value: str): self._box.setStatusTip(value) self._sld.setStatusTip(value) @property def spinbox_suffix(self) -> str: return self._box.suffix() @spinbox_suffix.setter def spinbox_suffix(self, value: str): self._box.setSuffix(value) @property def slider_visible(self) -> bool: return self._sld.isVisible() @slider_visible.setter def slider_visible(self, value: bool): self._sld.setVisible(value) def set_range(self, minimum: float, maximum: float): if minimum >= maximum: raise ValueError( f'Minimum must be less than maximum: min={minimum} max={maximum}' ) self.minimum = minimum self.maximum = maximum def update_atomically(self, minimum: typing.Optional[float] = None, maximum: typing.Optional[float] = None, step: typing.Optional[float] = None, value: typing.Optional[float] = None): """ This function updates all of the parameters, and invokes the change event only once at the end, provided that the new value is different from the old value. Parameters that are set to None will be left as-is, unchanged. """ if (minimum is not None) and (maximum is not None): if minimum >= maximum: raise ValueError( f'Minimum must be less than maximum: min={minimum} max={maximum}' ) original_value = self.value with self._with_events_suppressed(): if minimum is not None: self.minimum = minimum if maximum is not None: self.maximum = maximum if step is not None: self.step = step if value is not None: self.value = value if original_value != self.value: self._value_change_event.emit(self.value) def _on_box_changed(self, value: float): if self._events_suppression_depth > 0: return with self._with_events_suppressed(): self._sld.setValue(self._value_to_int(value)) # The signal must be emitted in the last order, when the object's own state has been updated self._value_change_event.emit(value) def _on_sld_changed(self, scaled_int_value: int): if self._events_suppression_depth > 0: return value = self._value_from_int(scaled_int_value) with self._with_events_suppressed(): self._box.setValue(value) # The signal must be emitted in the last order, when the object's own state has been updated self._value_change_event.emit(value) def _value_to_int(self, value: float) -> int: return round(value / self._box.singleStep()) def _value_from_int(self, value: int) -> int: return value * self._box.singleStep() def _refresh_invariants(self): assert self._events_suppression_depth >= 0 self._sld.setTickInterval( (self._sld.maximum() - self._sld.minimum()) // 2) # noinspection PyUnresolvedReferences @contextmanager def _with_events_suppressed(self): assert self._events_suppression_depth >= 0 self._events_suppression_depth += 1 yield self._events_suppression_depth -= 1 assert self._events_suppression_depth >= 0
class Register: """ Representation of a device register. None of the fields can be changed, except Value. When a new value is written, the class will attempt to coerce the new value into the required type. If a coercion cannot be performed or is ambiguous, an exception will be thrown. Note that this type is hashable and can be used in mappings like dict. """ ValueType = ValueType ValueKind = ValueKind def __init__( self, name: str, value: StrictValueTypeAnnotation, default_value: typing.Optional[StrictValueTypeAnnotation], min_value: typing.Optional[StrictValueTypeAnnotation], max_value: typing.Optional[StrictValueTypeAnnotation], type_id: ValueType, flags: Flags, update_timestamp_device_time: Decimal, set_get_callback: SetGetCallback, update_timestamp_monotonic: float = None, ): self._name = str(name) self._cached_value = value self._default_value = default_value self._min_value = min_value self._max_value = max_value self._type_id = ValueType(type_id) self._update_ts_device_time = Decimal(update_timestamp_device_time) self._update_ts_monotonic = float(update_timestamp_monotonic or time.monotonic()) self._flags = flags self._set_get_callback = set_get_callback self._update_event = Event() @property def name(self) -> str: return self._name @property def cached_value(self) -> StrictValueTypeAnnotation: return self._cached_value @property def default_value(self) -> typing.Optional[StrictValueTypeAnnotation]: return self._default_value @property def has_default_value(self) -> bool: return self._default_value is not None @property def cached_value_is_default_value(self) -> bool: if not self.has_default_value: return False if self.type_id in (ValueType.F32, ValueType.F64): # Absolute tolerance equals the epsilon as per IEEE754 absolute_tolerance = { ValueType.F32: 1e-6, ValueType.F64: 1e-15, }[self.type_id] # Relative tolerance is roughly the epsilon multiplied by 10...100 relative_tolerance = { ValueType.F32: 1e-5, ValueType.F64: 1e-13, }[self.type_id] return all( map( lambda args: math.isclose(*args, rel_tol=relative_tolerance, abs_tol=absolute_tolerance), itertools.zip_longest(self.cached_value, self.default_value), )) else: return self.cached_value == self.default_value @property def min_value(self) -> typing.Optional[StrictValueTypeAnnotation]: return self._min_value @property def max_value(self) -> typing.Optional[StrictValueTypeAnnotation]: return self._max_value @property def has_min_and_max_values(self) -> bool: return self._min_value is not None and self._max_value is not None @property def type_id(self) -> ValueType: return self._type_id @property def kind(self) -> ValueKind: return VALUE_TYPE_TO_KIND[self.type_id] @property def update_timestamp_device_time(self) -> Decimal: return self._update_ts_device_time @property def update_timestamp_monotonic(self) -> float: return self._update_ts_monotonic @property def mutable(self) -> bool: return self._flags.mutable @property def persistent(self) -> bool: return self._flags.persistent @property def update_event(self) -> Event: """ Event arguments: Reference to the emitting Register instance. """ return self._update_event async def write_through( self, value: RelaxedValueTypeAnnotation) -> StrictValueTypeAnnotation: """ Sets the provided value to the device, then requests the new value from the device, at the same time updating the cache with the latest state once the response is received. The new value is returned, AND an update event is generated. Beware that the cache may remain inconsistent until the response is received. """ value = self._stricten(value) # Should we update the cache before the new value has been confirmed? Probably not. v, dt, mt = await self._set_get_callback(value) self._sync(v, dt, mt) return v async def read_through(self) -> StrictValueTypeAnnotation: """ Requests the value from the device, at the same time updating the cache with the latest state. The new value is returned, AND an update event is generated. """ v, dt, mt = await self._set_get_callback(None) self._sync(v, dt, mt) return v @staticmethod def get_numpy_type(type_id: ValueType) -> typing.Optional[numpy.dtype]: try: return SCALAR_VALUE_TYPE_TO_NUMPY_TYPE[type_id] except KeyError: return None def _sync( self, value: RelaxedValueTypeAnnotation, device_time: Decimal, monotonic_time: float, ): """This method is invoked from the Connection instance.""" self._cached_value = value self._update_ts_device_time = Decimal(device_time) self._update_ts_monotonic = float(monotonic_time) self._emit_update_event() def _emit_update_event(self): self._update_event.emit(self) @staticmethod def _stricten( value: RelaxedValueTypeAnnotation) -> StrictValueTypeAnnotation: scalars = int, float, bool if isinstance(value, scalars): return [value] elif isinstance(value, (str, bytes)): return value else: try: return [x for x in value] # Coerce to list except TypeError: raise TypeError( f"Invalid type of register value: {type(value)!r}") def __str__(self): # Monotonic timestamps are imprecise, so we print them with a low number of decimal places. # Device-provided timestamps are extremely accurate (sub-microsecond resolution and precision). # We use "!s" with the enum, otherwise it prints as int (quite surprising). out = ( f"name={self.name!r}, type_id={self.type_id!s}, " f"cached={self.cached_value!r}, default={self.default_value!r}, " f"min={self.min_value!r}, max={self.max_value!r}, " f"mutable={self.mutable}, persistent={self.persistent}, " f"ts_device={self.update_timestamp_device_time:.9f}, ts_mono={self.update_timestamp_monotonic:.3f}" ) return f"Register({out})" __repr__ = __str__ def __hash__(self): return hash(self.name + str(self.type_id)) def __eq__(self, other): if isinstance(other, Register): return (self.name == other.name) and (self.type_id == other.type_id) else: return NotImplemented