plesty.lib.ui.device_panel ========================== .. py:module:: plesty.lib.ui.device_panel .. autoapi-nested-parse:: The panel that shows a device's controls, derived from what it declares. A device already describes its controls completely. This, from a spectrometer's schema, is a widget specification:: ConfigParameter(name='exposure', dtype=float, unit='ms', default=100.0, min_value=0.0, max_value=None, options=None, read_only=False, description='Integration time …') ``dtype`` picks the control, ``min_value``/``max_value`` bound it, ``options`` makes it a drop-down, ``read_only`` makes it a readout, ``unit`` is the suffix and ``description`` is the tooltip. Hand-writing that duplicates the schema, and the duplicate drifts: the panel written by hand for one spectrometer bounded an exposure at 0.01–60000 ms where the device declares no upper bound at all — a limit that exists nowhere in the instrument, silently constraining whoever uses it. So :func:`controls_for` derives the controls instead, and :class:`DevicePanel` renders them. What it deliberately does not do: - **Device-specific views stay out.** A spectrum, a beam image, a stage map are not derivable from a schema; they are monitors, and a :class:`~plesty.lib.ui.MonitorPanel` docks beside this one. - **Not every operation belongs on a panel.** Homing a stage or replacing a dark reference should not be one click away, so operations declared ``lifecycle`` are left out unless asked for by name. - **A monitor holds no device; this necessarily does.** It drives an instrument somebody may be measuring with, which is why it starts read-only. .. code-block:: python from plesty.lib.ui import DevicePanel, LocalHandle, run run([DevicePanel(LocalHandle(spectrometer), name="cct", include=("exposure", "hardware_average"))]) Attributes ---------- .. autoapisummary:: plesty.lib.ui.device_panel.CONTROL_KINDS plesty.lib.ui.device_panel.DEFAULT_SKIP_KINDS plesty.lib.ui.device_panel._NUMERIC plesty.lib.ui.device_panel.REPEATABLE_OP_KINDS plesty.lib.ui.device_panel.DEFAULT_POLL_S plesty.lib.ui.device_panel.DEFAULT_GROUP Classes ------- .. autoapisummary:: plesty.lib.ui.device_panel.Control plesty.lib.ui.device_panel.DevicePanel Functions --------- .. autoapisummary:: plesty.lib.ui.device_panel._label_for plesty.lib.ui.device_panel._full_key plesty.lib.ui.device_panel._kind_for plesty.lib.ui.device_panel._declared_types plesty.lib.ui.device_panel._tuple_or_none plesty.lib.ui.device_panel._parameter_control plesty.lib.ui.device_panel._argument_control plesty.lib.ui.device_panel._operation_control plesty.lib.ui.device_panel._output_control plesty.lib.ui.device_panel.controls_for Module Contents --------------- .. py:data:: CONTROL_KINDS :type: tuple[str, Ellipsis] :value: ('number', 'choice', 'toggle', 'text', 'readout', 'action') .. py:data:: DEFAULT_SKIP_KINDS :type: tuple[str, Ellipsis] :value: ('lifecycle',) .. py:data:: _NUMERIC .. py:data:: REPEATABLE_OP_KINDS :type: tuple[str, Ellipsis] :value: ('read', 'acquire') .. py:data:: DEFAULT_POLL_S :value: 1.0 .. py:class:: Control One rendered control, as derived from the device's own declaration. :ivar key: Configuration key or operation name. :ivar kind: One of :data:`CONTROL_KINDS`. :ivar label: Text shown beside the control. :ivar dtype: Declared type name, as it crossed the wire. :ivar group: Configuration group the parameter belongs to. :ivar unit: Physical unit, shown as the editor's suffix. :ivar minimum: Lower bound, or ``None`` when the device declares none. :ivar maximum: Upper bound, or ``None`` when the device declares none. :ivar options: Allowed values, which make the control a drop-down. :ivar default: Declared default. :ivar tooltip: The declaration's description. :ivar read_only: The device reports it; the operator cannot set it. :ivar write_only: The operator sets it; it cannot be read back, so it is never polled. :ivar arguments: For an ``action``, the operation's arguments as controls of their own — the same rules, one level down. :ivar outputs: For an ``action``, what it returns — name, type and unit — which is what lets a result be shown as a reading rather than as the mapping it arrives in. :ivar op_kind: For an ``action``, its :class:`~plesty.lib.device.device_utils.OpKind` kind. .. py:attribute:: key :type: str .. py:attribute:: kind :type: str .. py:attribute:: label :type: str .. py:attribute:: dtype :type: str :value: 'Any' .. py:attribute:: group :type: str :value: 'default' .. py:attribute:: unit :type: Optional[str] :value: None .. py:attribute:: minimum :type: Optional[float] :value: None .. py:attribute:: maximum :type: Optional[float] :value: None .. py:attribute:: options :type: Optional[tuple[Any, Ellipsis]] :value: None .. py:attribute:: default :type: Any :value: None .. py:attribute:: tooltip :type: str :value: '' .. py:attribute:: read_only :type: bool :value: False .. py:attribute:: write_only :type: bool :value: False .. py:attribute:: arguments :type: tuple[Control, Ellipsis] :value: () .. py:attribute:: outputs :type: tuple[Control, Ellipsis] :value: () .. py:attribute:: op_kind :type: str :value: '' .. py:attribute:: required :type: bool :value: True .. py:property:: types :type: frozenset[str] The types this declaration allows, a union split into its members. ``None`` is not among them: it is not a type an editor can offer, and what is left is what the control has to be able to send. Switch on this rather than on :attr:`dtype`, which is one string and may be a union. .. py:property:: omittable :type: bool Whether leaving this argument alone means sending nothing. An optional argument the device gives no default for has an answer already — its own — and the panel has no way to show one. A spin box always holds a number, so an untouched one said ``0``, and ``0`` is a real value: ``wait_settled`` read it as a zero-second budget and timed out at once. Such an argument is shown as unset and left out of the call instead. .. py:property:: repeatable :type: bool Whether holding this control should keep asking the device. Any readable parameter. An operation only when the module declares it :data:`REPEATABLE_OP_KINDS` — a reading or an acquisition, never a move or a stop. .. py:property:: readable :type: bool Whether polling this control makes sense. .. py:property:: writable :type: bool Whether the operator can send this control anywhere. .. py:function:: _label_for(name: str) -> str Turn a schema key into something readable. :param name: The declared key, conventionally ``lower_snake_case``. :returns: The key with separators as spaces and the first letter capitalised. .. py:data:: DEFAULT_GROUP :value: 'default' .. py:function:: _full_key(group: str, name: str) -> str Address a parameter the way the device resolves it. A grouped parameter is addressed as ``group.parameter`` and a device refuses the bare key outright — ``Grouped parameter 'CustomZero' requires full key format 'group_prefix.param_key'``. The panel used to derive its key from the parameter's name alone, so every read and every write of a grouped parameter was refused by the device it was built from. The group's own name is what is used here. A device may also give a group a command prefix, but it resolves either, and the prefix is not in the serialized model this panel is built from. :param group: The configuration group the parameter was declared in. :param name: The parameter's declared name. :returns: The key the device answers to. .. py:function:: _kind_for(dtype: str, options: Any, read_only: bool) -> str Decide which control a declaration asks for. Order matters: a read-only parameter is a readout whatever its type, so that an enumerated reading is not offered as a drop-down that cannot be set. :param dtype: Declared type name. :param options: Declared allowed values, if any. :param read_only: Whether the device refuses writes to it. :returns: One of :data:`CONTROL_KINDS`. .. py:function:: _declared_types(dtype: str) -> frozenset[str] The types a declaration allows, a union split into its members. An optional number is declared ``float | None``, and read whole it is neither ``float`` nor ``int``: the parameter fell through to a text field, which hands the device the string an operator typed. A timeout entered as ``10000`` then reached the instrument as ``"10000"`` and the device failed comparing a `str` to an `int`. ``None`` is not a type an editor can offer, so it is dropped: what is left is what the control has to be able to send. :param dtype: The declared type name, possibly a union. :returns: The member names, without ``None``. .. py:function:: _tuple_or_none(value: Any) -> Optional[tuple[Any, Ellipsis]] Return *value* as a tuple, or ``None`` when it holds nothing. .. py:function:: _parameter_control(spec: dict[str, Any], group: str) -> Control Build the control one configuration parameter asks for. :param spec: One entry of a serialized doc model's ``parameters``. :param group: The configuration group it came from. :returns: The derived control. .. py:function:: _argument_control(spec: dict[str, Any]) -> Control Build the control one operation argument asks for. An argument declares its bounds as a ``range`` pair rather than as two fields, and is never read-only — otherwise the rules are the parameters'. :param spec: One entry of an operation's ``iparams``. :returns: The derived control. .. py:function:: _operation_control(spec: dict[str, Any]) -> Control Build the control one operation asks for. :param spec: One entry of a serialized doc model's ``functions``. :returns: An ``action`` control carrying the operation's arguments. .. py:function:: _output_control(spec: dict[str, Any]) -> Control Describe one value an operation returns. :param spec: One entry of an operation's ``oparams``. :returns: A ``readout`` control — it is a value the device reports, which is what a result is. .. py:function:: controls_for(doc: Optional[dict[str, Any]], *, include: Optional[Sequence[str]] = None, exclude: Iterable[str] = (), operations: Optional[Sequence[str]] = None, skip_kinds: Iterable[str] = DEFAULT_SKIP_KINDS) -> list[Control] Derive a device's controls from its serialized doc model. This is the whole schema-to-widget mapping, and it is deliberately free of any toolkit: it is where the bounds, units and options come from, so it is where a drifting panel would be caught, and a plain test can catch it. :param doc: A :meth:`~plesty.lib.device.doc.DeviceDocModel.to_dict` payload — from a ``describe`` reply or from the device itself. ``None`` (an older server, or a device whose model failed to build) yields no controls rather than an error. :param include: Parameter keys to show, in this order. ``None`` shows every declared parameter. A grouped parameter is named as the device addresses it, ``group.parameter``. :param exclude: Parameter keys to leave out, applied after *include*. :param operations: Operation names to offer, in this order. ``None`` offers every operation whose kind is not in *skip_kinds*; naming one overrides that, so an operation can always be asked for explicitly. :param skip_kinds: Operation kinds left out when *operations* is ``None``. :returns: The parameters in declaration order, then the operations. .. py:class:: DevicePanel(handle: plesty.lib.ui.device_link.DeviceHandle, *, doc: Optional[dict[str, Any]] = None, include: Optional[Sequence[str]] = None, exclude: Iterable[str] = (), operations: Optional[Sequence[str]] = None, skip_kinds: Iterable[str] = DEFAULT_SKIP_KINDS, writable: bool = False, poll_s: float = DEFAULT_POLL_S, theme: Optional[plesty.lib.ui.theme.Theme] = None, **kwargs: Any) Bases: :py:obj:`plesty.lib.ui.panel.Panel` Sub-window holding one device's controls. Constructing the panel touches neither a toolkit nor the device: it is built when a shell adds it, and it asks the device what it offers on its own worker thread, so a slow or absent instrument delays nothing. Until that answer arrives the panel says so. The panel starts **read-only**. It may be pointed at an instrument an experiment is driving, and a stray write into a running measurement is worse than a click: :meth:`set_writable` is what a *Take control* toggle calls. Wrap *handle* into a dockable control panel. :param handle: What the panel drives — see :class:`~plesty.lib.ui.device_link.DeviceHandle`. :param doc: The device's serialized doc model when the caller already has it (a console that just called ``describe``); ``None`` makes the panel fetch it. :param include: Parameter keys to show, in order. :param exclude: Parameter keys to leave out. :param operations: Operations to offer, in order. :param skip_kinds: Operation kinds left out by default. :param writable: Start with writes and operations enabled. The default is read-only. :param poll_s: Seconds between re-reads of the device. :param theme: Palette; ``None`` loads the packaged one. :param \*\*kwargs: Forwarded to :class:`~plesty.lib.ui.panel.Panel`. .. py:attribute:: kind :type: ClassVar[str] :value: 'device' Panel category, used for layout keys and styling hooks. .. py:attribute:: handle .. py:attribute:: theme :value: None .. py:attribute:: writable :value: False .. py:attribute:: poll_s :value: 1.0 .. py:attribute:: live :type: dict[str, dict[str, Any]] .. py:attribute:: controls :type: list[Control] :value: [] .. py:attribute:: values :type: dict[str, Any] .. py:attribute:: worker .. py:attribute:: form :type: Any :value: None .. py:attribute:: _selection .. py:attribute:: _doc :value: None .. py:attribute:: _note :value: '' .. py:attribute:: _tone :value: 'idle' .. py:attribute:: _latency :value: 0.0 .. py:attribute:: _polled_at :value: 0.0 .. py:method:: _selection_kwargs() -> dict[str, Any] Return the selection arguments :func:`controls_for` was configured with. .. py:method:: open() -> None Start the worker and ask the device what it offers. Separate from :meth:`build` so the device-facing half of the panel — which is all of the interesting behaviour — can be driven with no display and no toolkit: open it, :meth:`tick` it, and read :attr:`controls` and :attr:`values`. .. py:method:: build(parent: Any = None) -> Any Create the form, then open the device. :param parent: The parent widget provided by the shell. :returns: The form widget. .. py:method:: tick() -> None Apply whatever the worker finished, then poll if polling is on. Both halves run on the shell's clock and neither blocks: this is the only place results cross back to the GUI thread, and the coalescing in :class:`~plesty.lib.ui.device_link.DeviceWorker` is what keeps a slow device from building a backlog it can never work off. Polling is off until it is asked for — see :meth:`set_live`. .. py:method:: control(key: str) -> Optional[Control] The control named *key*, or ``None``. .. py:method:: _read(key: str, arguments: Optional[dict[str, Any]] = None) -> None Ask the device about one control: a parameter's value, or an operation. .. py:method:: read_once(key: Optional[str] = None, arguments: Any = None) -> None Ask the device once: for *key*, or for every value the panel shows. :param key: The control to read; ``None`` reads every readable one. :param arguments: For an operation, what to call it with. .. py:method:: set_live(key: str, live: bool, arguments: Any = None) -> None Start or stop re-reading one control on the shell's clock. :param key: The control to keep reading. :param live: Whether to keep reading it. :param arguments: For an operation, what to keep calling it with. Taken once, when it starts: an operation repeating on a timer should go on asking the same question, not whatever the form happens to hold when the timer next fires. .. py:method:: is_live(key: str) -> bool Whether *key* is being re-read. .. py:method:: status() -> str Return the line shown in the panel's title bar. :returns: What the panel is doing, or what went wrong. .. py:method:: tone() -> str Return the state colour for the panel's dot. :returns: ``error`` when the last call failed, ``warning`` when the device offered no schema, ``running`` once controls are on screen. .. py:method:: close() -> None Stop the worker thread. .. py:method:: describe() -> dict[str, Any] Return the panel description including what it drives. :returns: The panel's placement plus its controls and whether it is writable. .. py:method:: set_writable(writable: bool) -> None Enable or disable every editor and button. :param writable: Whether the operator may drive the device. .. py:method:: request_write(key: str, value: Any) -> None Send a parameter change to the device. :param key: Configuration key. :param value: The value the operator entered. .. py:method:: request_call(op: str, arguments: Optional[dict[str, Any]] = None) -> None Run an operation on the device. :param op: Operation name. :param arguments: The operation's arguments. .. py:method:: _apply(result: plesty.lib.ui.device_link.Result) -> None Fold one finished call into the panel's state and its form. :param result: What the worker handed back. .. py:method:: _receive_doc(describe: Any) -> None Derive the controls from a ``describe`` reply and fill the form. :param describe: The reply, or ``None``. .. py:method:: _fail(result: plesty.lib.ui.device_link.Result) -> None Record a failed call in the title bar. :param result: The failed result.