示例#1
0
    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)
示例#2
0
    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()
示例#3
0
    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()
示例#4
0
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()
示例#5
0
文件: __init__.py 项目: Zubax/kucher
    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()
示例#6
0
    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()
示例#7
0
    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)
示例#8
0
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)
示例#9
0
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
示例#10
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