Matr1x Matr1x
  • User Guide
  • Reference
  • Changelog

Skills

A skill is a package of structured files that teaches an AI coding agent how to work with a specific tool or framework. This project ships multiple skills — use the switcher below to browse each one. Install a skill in your agent and it will be able to run commands, edit configuration, write content, and troubleshoot problems without step-by-step guidance from you.

Any agent — install all with npx:

npx skills add https://andythomas.github.io/matr1x/

CLI — install all skills in a project:

great-docs skill install matr1x-measurements

Codex / OpenCode

Tell the agent to fetch these skill files:
https://andythomas.github.io/matr1x/.well-known/agent-skills/matr1x-install/SKILL.md
https://andythomas.github.io/matr1x/.well-known/agent-skills/matr1x-migration/SKILL.md
https://andythomas.github.io/matr1x/.well-known/agent-skills/matr1x-security/SKILL.md
https://andythomas.github.io/matr1x/.well-known/agent-skills/matr1x-write-control/SKILL.md

Or browse the skill files below.

SKILL.md

---
name: matr1x-install
description: >
  Use to install and setup the matr1x package.
license: GNU General Public License v3 or later (GPLv3+)
compatibility: Requires Python >=3.10.
---

# Matr1x

Python tools for data recording, instrument control and visualization.

## Installation

1. On a Linux system, install the required qt6 library via `sudo apt install qt6-base-dev`
2. Follow the [uv installation](https://docs.astral.sh/uv/getting-started/installation/) procedure if `uv` is not already installed.
3. Clone the gitub repository `git clone https://github.com/andythomas/matr1x.git` in the desired location (called pkg-root from now on).
4. Execute `uv sync` in pkg-root.
5. Activate the virtual environment in pkg-root according to the OS, i.e. `source .venv/bin/activate` on Linux/MacOS, `.\.venv\Scripts\activate.bat` on Windows.
6. Ensure that `~/.matr1x.toml` exists and includes the following content. If the file does not exist, create it with this content. If it exists, add this content only if it is not already present:

```toml
[matr1x.install]
controlguis = ["control-dummy"]
```

7. If not, either add the content to the existing file or generate the file with the content.
8. Run the disktop integration in the pkg-root folder via `matrix-di` 
9. Launch `matrix-script` in the pkg-root folder. This will take a minute or two, because the editor-assets will be downloaded.

In case there are any errors in the last two steps, please inspect the newest files in `~/logs/` for the underlying cause, attempt to fix the issue, and repeat the failed step.

## Workspace

If the user has a folder with their own system files, control GUIs, and
device drivers (or wants to create one), set it up as a matr1x workspace:

1. Register the folder in `~/.matr1x.toml` under a new section named after
   the folder, using the absolute path of the folder:

   ```toml
   [mylab]
   systems_directory = "/home/user/labs/mylab"
   ```

   If the section already exists, update it instead of adding a duplicate.

2. Create or update an `AGENTS.md` file in the folder, adjusting the
   following content to the actual section name and paths:

   ```markdown
   # matr1x workspace

   This folder contains my matr1x system files, control GUIs, and device
   drivers. It is registered in `~/.matr1x.toml` under the section
   `[mylab]`.

   ## Environment

   - matr1x installation (pkg-root): `/home/user/matr1x`
   - Run Python from this folder with the installation's environment:
     `uv run --project /home/user/matr1x python <script>`
   - Package layout and coding conventions: `/home/user/matr1x/AGENTS.md`
   - Task-specific procedures: the `matr1x-*` skills in
     `/home/user/matr1x/skills/` (or installed globally).
   ```

SKILL LAYOUT

migration/
├── SKILL.md
└── references/
    ├── migration-v8.3.md
    ├── migration-v8.4.md
    ├── migration-v8.5i.md
    ├── migration-v8.5ii.md
    ├── migration-v8.6i.md
    ├── migration-v8.6ii.md
    └── migration-v8.6iii.md

SKILL.md

---
name: matr1x-migration
description: >
  Perform the required steps for an update of the matr1x package.
license: GNU General Public License v3 or later (GPLv3+)
compatibility: Requires Python >=3.10.
---

Please refer to the migration files in the reference subdirectory and perform the required steps. Start in ascending order with the lowest version number.

references/migration-v8.3.md

# Upgrade to v8.3

1. Check if there is a `~/.matr1x.toml` file 
2. If yes, check if the is a `[matr1x.install]` section.
3. If yes, delete the `options` and `pipoptions` keys (if present).

references/migration-v8.4.md

# Upgrade to v8.4

1. Check if there is a `~/.matr1x.toml` file 
2. If yes, check if the is a `[matr1x.scripts.matrix-script]` section.
3. If yes, check if there are entries for the `duplicate_output_to_logfile` and/or `print_to_comment` keys
4. Move the existing entries to the `[matr1x]` section of the file. If that section does not exist, create it.

references/migration-v8.5i.md

# Upgrade to v8.5

## Enforce unique systems classes

1. Please inspect the files in the `systems` subdirectory of the repository.
2. In every file there should be an import of the `System` class.
3. In every file there should be one and only one `system =` line in scope of the module.
4. The right side of this assignment should be a subclass of the imported `System` class. If it only instantiates `System`, e.g. `system = System()`, subclass `System` as in the following example.

```python
class MeasSystem(System):
    """Measurement system for dummy feature demonstration."""
```

A good name might be the filename of the module. 
If this name starts with `system_` strip this part from the class name. 

5. CLASS NAME UNIQUENESS CHECK (applies to all files, independent of steps 1-4):
   a. Search ALL .py files in this directory for any shared subclass name
      (especially `MeasSystem`).
   b. Every file must have a UNIQUE class name — no two files may use the same one.
   c. Rename all occurrences. Derive names from the filename (minus `system_` prefix)
      or from the hardware described in dcdata.
   d. Files already using unique names do NOT need changes.

6. Make sure you did not touch the comments or docstrings. The only exception is the replacement of an referral of an old name to the new name of the class.

## Control Command Migration

This skill migrates legacy patterns in matr1x files:

**`cmds` dict entries** — raw lists or `Command.from_deprecated_list()` calls → `Command`, `Get`, or `Set` instantiation

### Legacy formats to look for

**Raw list values** inside a dict:
```python
cmds = {
    ":key": [dtype, setfunc, setargs, getfunc, getargs],  # length 5
    ":key": [dtype, setfunc, setargs, getfunc, getargs, polling_cmd],  # length 6
}
```

### Conversion rules

Apply these rules to each entry (prefer `Get`/`Set` over `Command` when possible):

| setfunc | getfunc | Use |
|---------|---------|-----|
| not `None` | not `None` | `Command(dtype, setfunc, getfunc, ...)` |
| `None` | not `None` | `Get(dtype, getfunc, ...)` |
| not `None` | `None` | `Set(dtype, setfunc, ...)` |

Optional keyword arguments — **only include if non-empty / not `None`**:
- `setargs` — omit if `None`, `()`, or `[]`
- `getargs` — omit if `None`, `()`, or `[]`
- `polling_cmd` — omit if `None`

#### Before / After example

**Before:**
```python
from matr1x.util import Command

cmds = {
    ":temp": [float, "setTemp", (), "getTemp", ()],
    ":field": [float, None, (), "getField", ()],
    ":press": [float, "setPress", (1,), None, (), ":pressrd"],
    ":v2": [float, ("dummy", "p2"), None, "V2", None, ":v2rd"],
    ":info": Command.from_deprecated_list([str, None, None, "getInfo", None]),
}
```

**After:**
```python
from matr1x.util import Command, Get, Set

cmds = {
    ":temp": Command(float, "setTemp", "getTemp"),
    ":field": Get(float, "getField"),
    ":press": Set(float, "setPress", setargs=(1,), polling_cmd=":pressrd"),
    ":v2": Command(float, ("dummy", "p2"), "V2", polling_cmd=":v2rd"),
    ":info": Get(str, "getInfo"),
}
```

---

### Steps to perform the migration

1. **Identify files** — scan for files in the control subdirectories containing `cmds = {`,`common_commands = {`, `cmd_list = {` or similar patterns with list values.

2. **Read each file** fully before making changes.

3. **Apply changes** convert the dict entries that are still a list 

4. **Fix imports** — ensure the file imports exactly the classes it uses:
   - Add `Get` and/or `Set` and/or `Command` to the import if they are now used.
   - Remove `Get`, `Set`, or `Command` from the import if they are no longer used.
   - The import comes from `matr1x.util`.

references/migration-v8.5ii.md

# Upgrade to v8.5

## Move from System Instance to System Class

Define system setup on exactly one local `System` subclass. `System.from_file` discovers and
instantiates that class, so no module-level `system` variable is required.
Initialized `system` and `sys` exports remain temporarily supported with a deprecation warning.

## Migration

1. Read the whole system file.
2. There must be exactly one class defined in the system file that is a subclass of `System`.
3. If there is no class and `System` or a subclass of it is only instantiated (e.g. `MySystem = System()`), create a subclass with an appropriate name. A good name could be the instance name (e.g. `MySystem`). This class is called "SystemClass" from here on.
4. If no `__init__` exists for the SystemClass, add one and call `super().__init__()` first. Please double-check that no `__init__` exists before adding one.
5. Now identify module-level mutations of `system`: `add_dev`, `add_param`, as well as `dcdata` assignments, and `load_config`. These instance mutation need to be moved into the SystemClass, which at least requires a change of `system.` to `self.`.
6. Preserve the imports, it is not required to add or remove imports.
7. Preserve comments as source: move each comment or section heading with the adjacent setup block into `__init__`. Keep its wording unless changing `system` to `self` is needed for accuracy; do not drop instructional comments merely because their code moves.
8. Preserve order: configuration and metadata must be initialized before any setup that relies on them, and device/parameter registrations should retain their former order.
9. Now, inspect the remaining items in the module for any items that need to be moved into the SystemClass. In particular, this includes functions that use the former `system` instance and need to become methods of SystemClass, which again at least requires a change of `system.` to `self.`.

## Examples

Before:

```python
from matr1x.devices.dummy import dummy
from matr1x.system import System


class Example(System):
    pass


system = Example()
system.dcdata["source"] = "example"
system.add_dev("device", dummy, args=("TCPIP::localhost::10007::SOCKET",))
system.add_param("voltage", "V", getter=["device", "voltage"])
```

After:

```python
from matr1x.devices.dummy import dummy
from matr1x.system import System


class Example(System):
    def __init__(self):
        super().__init__()
        self.dcdata["source"] = "example"
        self.add_dev("device", dummy, args=("TCPIP::localhost::10007::SOCKET",))
        self.add_param("voltage", "V", getter=["device", "voltage"])
```

## Import check

Use a focused import check after the change for every file:

```python
from pathlib import Path
from matr1x.error_handling import Success
from matr1x.system import System

result = System.from_file(Path("path/to/system_file.py"))
assert isinstance(result, Success)
```

## Steps to Perform the Migration

1. **Identify files** — scan for files in the system subdirectories. Most likely the filenames have a `system_`-prefix.

2. **Read each file** fully before making changes.

3. **Apply changes** as described in the "Migration" step by step instructions.

4. **Validate changes** using the following two checks:
   - `ty check`: This should catch any remaining items that need to be moved in the SystemClass.
   - Import check (as described above): This should catch the additional errors.

5. **Perform additonal changes** as required by the previous step and repeat validation until all errors are resolved.

references/migration-v8.6i.md

# Upgrade to v8.6

## Validate system configuration defaults

`System.load_config()` needs to be switched from `BaseModel` classes to
`SystemConfigModel` to enable validation of their default values. For migration
perform the following steps:

1. Find every Pydantic model passed to `load_config(...)` in a system file.
   Do not change unrelated `BaseModel` classes.
2. Replace `BaseModel` with `SystemConfigModel` for each system configuration model:

```python
# Before
from pydantic import BaseModel, Field


class DeviceConfig(BaseModel):
    mode: Literal["CURR", "VOLT"] = Field("VOLT")
```

```python
# After
from pydantic import Field

from matr1x.models import SystemConfigModel


class DeviceConfig(SystemConfigModel):
    mode: Literal["CURR", "VOLT"] = Field("VOLT")
```

3. Preserve other Pydantic model configuration options. Pydantic merges them with the
   inherited `validate_default=True` setting:

```python
class DeviceConfig(SystemConfigModel):
    model_config = ConfigDict(extra="forbid")
```

4. Validate the model after migration. Instantiate it without arguments when it has no
   required fields; otherwise supply representative values for the required fields. Defaults
   must satisfy the same `Literal`, numeric, string, and custom constraints as configured
   values. Fix an invalid default or make that field required with `Field(...)`; do not
   disable default validation.
5. If a configuration model already uses a custom Pydantic base class, do not replace it
   blindly. Make that base inherit `SystemConfigModel`, or add
   `ConfigDict(validate_default=True)` while preserving its existing behavior.

references/migration-v8.6ii.md

# Upgrade to v8.6

## Give Control GUI Systems Unique Names

Every `System` used by a `GuiDict` in the same `ControlWindow` must have a
unique name. The name is used to expose the system through the merged system,
for example as `window.S.temperature`.

The name must:

- be unique among all `GuiDict` systems in the `ControlWindow`; and
- be a valid Python identifier according to `str.isidentifier()`; and
- not be a Python keyword such as `class` or `return`.

For example, `temperature`, `magnet_2`, and `cryostat` are valid names.
Names containing spaces, hyphens, or dots, such as `temperature control`,
`magnet-2`, or `lab.cryostat`, are not valid.

## Migration

1. Find the `GuiDict` classes used by each control GUI.
2. Check whether each class explicitly defines an `S` attribute.
3. If a `GuiDict` does not define `S`, no change is required. `GuiDict`
   automatically creates an empty system named after the `GuiDict` class.
4. If a `GuiDict` explicitly defines `S`, ensure that its `System` has a name
   that meets both requirements above.
5. Check all `GuiDict` systems passed to the same `ControlWindow` together and
   resolve any duplicate names.

Prefer supplying the name when the system is created:

```python
from matr1x.control import GuiDict
from matr1x.system import System


class TemperatureGui(GuiDict):
    S = System(name="temperature")
```

If a custom `System` subclass does not accept `name` as an initializer
argument, set the name explicitly after creating it:

```python
class TemperatureGui(GuiDict):
    S = TemperatureSystem()
    S.name = "temperature"
```

A separate `System` subclass is not required merely to give each control GUI
system a unique name.

## Validation

Start each migrated control GUI and verify that it opens without a missing,
invalid, or duplicate system-name error. If the merged system is accessed
directly, also verify that every subsystem is available under its configured
name, for example:

```python
control_window.S.temperature
```

references/migration-v8.6iii.md

# Upgrade to v8.6

## Rename the `[matr1x.scripts]` TOML section to `[matr1x.apps]`

The configuration section names the applications. The section was renamed from
`[matr1x.scripts]` to `[matr1x.apps]`.

1. Check if there is a `~/.matr1x.toml` file.
2. If yes, check if there is a `[matr1x.scripts]` section.
3. If yes, rename that section to `[matr1x.apps]`.
4. Within that section, replace the `matrix-script` subsection name with
   `matrix_script` (underscored, to correspond 1:1 with the Python attribute).
5. Keep the `shortcuts` subsection and its keys as they are.

SKILL.md

---
name: matr1x-security
description: >
  Address the security alerts.
license: GNU General Public License v3 or later (GPLv3+)
compatibility: Requires Python >=3.10.
---

# Security

Read security alerts and address them.

## Address alerts

1. Read the security alerts via `uv audit` for this repository.
2. There will be a recommendation for the version of the package to upgrade to.
3. Look into the local `pyproject.toml` file in the project's root directory.
4. If the affected package is listed there, upgrade it to the recommended version.
5. If the affected package is not listed there, add it to the `tool.uv` override-dependencies key in `pyproject.toml`.

## Example

```toml
[tool.uv]
override-dependencies = [
    "urllib3>=2.7.0", # CVE-2026-44432, CVE-2026-44431
    "idna>=3.15", # CVE-2026-45409
    "starlette>=1.0.1", # CVE-2026-48710
    "fastapi>=0.136.1", # forced by CVE-2026-48710
]
```

## Steps to perform the migration

1. **address alerts** - perform the steps outlined above.

2. **update repository** - run `uv sync --all-extras --all-groups` to update the repository and lock file.

3. **query user** - run all tests for the package

SKILL.md

---
name: matr1x-write-control
description: >
  Write a control file for a matr1x setup.
license: GNU General Public License v3 or later (GPLv3+)
compatibility: Requires Python >=3.10.
---

## 1. Writing a new control GUI

1. Create `control_<name>.py` with `common_commands = {"*idn": Get(str, ...)}`. Usually, just the "identify" command suffices.
2. Define one `GuiDict` subclass per instrument: `cmds`, `data`, `S`, optionally `refresh_period` / `allow_disabling`.
3. Implement `create_GUI` (button wiring), `refresh(count)` (read →`self[...].value`), `write` (setpoint → hardware), and `panic` (safe state) where relevant.
4. Build `clientdevice = makeSCPIdevice(*Cmds, common_commands, system=True)`.
5. Add `main()` calling `control_main(name, ControlWindow, guidicts=(...), extra_cmds=common_commands, package=..., port=...)`.

Read section 4.4 (concurrency and thread safety) before wiring the
`cmds` getters/setters and `refresh` — the refresh worker and the SCPI
server run in parallel and must not race each other.

Please find the details in the next sections.

## 2. The framework layer

Everything below builds on these classes and functions from the `matr1x.control` package:

| Class / function                        | File                         | Role                                                                                                                                                                                                                                           |
| --------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ControlWindow`                         | `controlwindow.py`           | `QMainWindow` base class. Owns the list of `GuiDict`s, the panic button, the logging window, the SCPI server, menus and the enable/disable logic.                                                                                              |
| `GuiDict`                               | `gui_dict.py`                | A `dict[str, var]` subclass. One `GuiDict` = one dockable panel + its device `System` + its refresh thread + its SCPI commands. This is the core building block of every control GUI file.                                                     |
| `var`                                   | `gui_dict.py`                | One row of a panel: a stored `.value`, a list of Qt `.widgets`, a `unit`, a `tooltip`, a `log` flag and `valueChanged`/`unitChanged`/`tooltipChanged` signals.                                                                                 |
| `guiObject`                             | `gui_dict.py`                | `IntEnum` naming the widget types used in `var(..., columns=...)`: `button`, `lineedit`, `checkbox`, `progressbar`, `combobox`, `togglebutton`, `spinbox`, `doublespinbox`, `labeltext`, `hline` (a plain `str` also works as a static label). |
| `MethodBundle`                          | `gui_dict.py`                | Reusable bundle of setup methods (`add_setup_method`) and change handlers (`add_change_handler`) applied to widgets via `var(..., modify=...)`. For more advanced customization.                                                               |
| `catchEmitError`                        | `gui_dict.py`                | Decorator for slot functions: catches exceptions and emits them to the window's error handling instead of crashing.                                                                                                                            |
| `control_main`                          | `util.py`                    | Entry-point helper: creates the Qt app, checks the lock file, instantiates `window_class` with the given `guidicts`/`extra_cmds` and starts the event loop.                                                                                    |
| `linear_trend`, `sendNotificationEmail` | `util.py`                    | Helpers for trend tooltips and email alerts.                                                                                                                                                                                                   |
| `Command`, `Get`, `Set`                 | `matr1x/core/commands.py`    | Containers for the SCPI command map: `Command(type, setfunc, getfunc)`, `Get(type, getfunc)`, `Set(type, setfunc)`.                                                                                                                            |
| `makeSCPIdevice`                        | `matr1x/devices/scpi_dev.py` | Builds a `clientdevice` from the `cmds` dicts so measurement systems can talk to the running GUI over SCPI.                                                                                                                                    |

---

## 3. General file structure

Every control GUI file follows a similar skeleton, one example is given in `matr1x/control/control_dummy.py`:

```python
# 1. Imports
from PySide6.QtWidgets import QDoubleSpinBox

from matr1x.control import (
    ControlWindow,
    GuiDict,
    MethodBundle,
    catchEmitError,
    control_main,
    linear_trend,
    var,
)
from matr1x.control import guiObject as go
from matr1x.core.commands import Command, Get
from matr1x.core.system import System
from matr1x.devices.scpi_dev import makeSCPIdevice

# 2. Common SCPI commands shared by all panels of this GUI
# Here, the idn command returns a string to identify the control
common_commands = {"*idn": Get(str, "dummy_control")}

# 3. Optional reusable MethodBundles (widget setup / change handlers)
# This can be used in more advanced cases to customize the standard
# widgets.
one_decimal = MethodBundle()


def set_decimals(widget: QDoubleSpinBox) -> None:
    """Set the number of decimals for a QDoubleSpinBox to 1."""
    widget.setDecimals(1)


one_decimal.add_setup_method(set_decimals)


# 4. One or more GuiDict subclasses  (see section 4)
class exampleDict(GuiDict): ...


class exampleDict2(GuiDict): ...


# 5. clientdevice: exposes the cmds of all panels to measurement systems
clientdevice = makeSCPIdevice(exampleDict.cmds, exampleDict2.cmds, common_commands, system=True)


# 6. main() entry point
def main():
    control_main(
        "dummy",  # name: window title + lock file
        ControlWindow,
        guidicts=(exampleDict, exampleDict2),
        extra_cmds=common_commands,
        port=8897,  # SCPI server port
        package="matr1x",  # used for log files / desktop file
    )
```

Responsibility split:

- **`GuiDict`** — one instrument or subsystem: its devices (`System`),
  its readout/setpoint widgets, its refresh loop, its SCPI commands.
- **`ControlWindow`** — the frame around the panels: panic handling,
  logging, SCPI server, menus.
- **`clientdevice`** — the measurement-system side of the running GUI.
- **`main()` / `control_main`** — wiring it all together.

---

## 4. `GuiDict` internal structure

A `GuiDict` subclass is fully described by its **class attributes** (the declaration) plus a few **overridden methods** (the behavior).

### 4.1 Class attributes (the declaration)

```python
class exampleDict(GuiDict):
    # SCPI command map: name -> Command/Get/Set bound to methods below
    cmds: ClassVar[dict[str, Command]] = {
        ":v1": Command(str, "setV1", "V1"),
        ":v2": Command(float, ("dummy", "p2"), "V2", polling_cmd=":v2rd"),
        ":v2rd": Get(bool, "v2ready"),
    }

    # The panel layout: one entry per row of the dock widget
    # Three columns in one row, one label and two for widgets
    data: ClassVar[dict[str, var]] = {
        "Example": var(None, columns=["Readout", "Setpoint"]),
        "V1": var(
            dtype=str, columns=[go.labeltext, go.combobox], log=True, init=[None, ("i1", "i2")]
        ),
        "V2": var(float, columns=[go.labeltext, go.lineedit], unit="mT"),
        "V3": var(
            dtype=float,
            columns=[go.progressbar, go.doublespinbox],
            unit="%",
            init=[0, (0, 100)],
            hide=True,
            modify=[one_decimal],
        ),
        "Set": var(None, columns=[go.button, go.button], init=["Set", "Copy"]),
    }

    # The device system behind this panel
    S = System(name="dummy")
    S.add_dev("dummy", dummy, args=(...), kwargs={...})

    refresh_period = 1.0
    allow_disabling = False
```

Explanations:

- `cmds` values reference methods of the `GuiDict` (by name or as `("devname", "attr")` device attribute paths). `polling_cmd` names a readiness command.

- `data`: The first key is used as the panel (dock widget) title.
- `var(...)` options: `dtype` (data type such as `int` or `float`), `columns` (see next point), `unit` (shown in the label and
  in the log file), `log` (if the row is logged by default, i.e. after start-up), `init` (initial values, spin box ranges, combo box entries, button texts), `hide` (only shown in the "extended" view), `modify` (`MethodBundle`s applied to widgets).
  - `columns` maps positions to widget types; the typical convention is: column 1 = readout, column 2 = setpoint. There is always the label plus two columns available.

- Every `GuiDict` typically adds one device, e.g., `S = System(name="dummy")` plus subsequent `add_dev` and its name must be unique.
  `add_dev(name, descriptor, args=..., kwargs=...)` takes a device class as
  `descriptor`, so import it at the top of the file (in the example below
  `dummy` comes from `from matr1x.devices.dummy import dummy`). The `args`
  hold the connection/adapter address the device needs (the `dummy` device
  requires the resource string, e.g. `args=("TCPIP::localhost::10006::SOCKET",)`,
  while its named properties go into `kwargs`).
- `refresh_period` defines the time in seconds between `refresh()` calls.
- `allow_disabling`: Let the panel get an enable/disable switch. Requires the devices to support close().

### 4.2 Overridden methods (the behavior)

| Method              | Thread        | Purpose                                                                                                                                                                                                                         |
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `__init__`          | GUI           | Call `super().__init__()`, create locks, deques for trends, `MethodBundle` change handlers, etc.                                                                                                                                |
| `create_GUI`        | GUI           | Call `super().create_GUI()`, then connect buttons/toggles to slot methods (e.g. `self["Set"].widgets[1].clicked.connect(self.write)`), add menu actions via `self.menu_actions`, restyle widgets.                               |
| `refresh(count)`    | worker thread | Read hardware via `self.S.devs[...]` and **only set `self[...].value`** (never touch widgets directly). `count` allows "every Nth iteration" tasks (tooltips, trends). May emit `self.refresh_worker.panic.emit(True, reason)`. |
| `write`             | GUI           | Push `self[...].getGUIvalue()` to the hardware (usually behind a lock). This is a naming convention and only wired manually to the "Set" button. See 4.4 for locking.                                                                                |
| `copy_values`       | GUI           | Copy readout columns into setpoint columns. Performed once automatically once at start-up. Usually, also wired to a "Copy" button.                                                                                              |
| `panic` / `unpanic` | GUI           | Bring the instrument to a safe state, disable set buttons; call `super().panic()` / `super().unpanic()`.                                                                                                                        |
| command methods     | either        | The functions named in `cmds` (e.g. `setV1`, `V1`, `v2ready`), often decorated with `@catchEmitError`. Prefer buffered getters, see 4.4.                                                                                                                          |

### 4.3 `var` (one row in a `GuiDict`)

- `var.value` — the stored value; setting it emits `valueChanged`, which updates the readout widget and any change handlers.
- `var.widgets` — `widgets[0]` is the label, `widgets[1:]` the other two columns.
- `var.getGUIvalue()` — value as entered in the setpoint widget.
- `var.tooltip`, `var.unit` — live-updatable, used for trend displays.

### 4.4 Concurrency and thread safety

A control GUI is implicitly multi-threaded: the **refresh worker
thread**
(runs `refresh`) and the **SCPI server threads** (run the `get`/`set`
functions bound to `cmds`) concurrently talk to the same instrument(s).
This is the most common source of hard-to-reproduce bugs, so follow
the rules below.

#### Which thread runs what

| Entry point            | Thread            | Hardware access?              |
| ---------------------- | ----------------- | ----------------------------- |
| `refresh(count)`       | refresh worker   | yes (reads)                   |
| SCPI `get` command     | SCPI server      | only if the getter reads HW   |
| SCPI `set` command     | SCPI server      | yes (writes)                  |
| GUI buttons / `write`  | GUI (Qt) thread  | yes, when the slot writes     |
| `panic` / `unpanic`    | GUI (Qt) thread  | yes, when the slot writes     |

So a given device may be accessed from up to three threads at once. You
must make sure the underlying communication is safe before relying
on it.

#### 1. Prefer buffered `get` commands

The primary idea of the control GUI is that `refresh()` keeps a buffered
snapshot of the instrument state in `self[...].value`, and getter
commands should **return that buffered value** instead of re-reading the
hardware. This avoids extra bus traffic and, more importantly, means a
`get` does not race the `refresh` thread.

```python
cmds = {
    # returns the value last cached by refresh() - no hardware access
    ":temperature": Get(float, "Temperature"),
}
```

The `Get` shorthand with a `GuiDict.data` key name as `getfunc` resolves
straight to `self["Temperature"].value`, so no I/O happens on the server
thread. Only break this pattern with a specific reason, e.g. a command
that must report a value `refresh` does not sample.

#### 2. Serialize every hardware-accessing getter/setter with the locks

For commands that genuinely touch the hardware, your device access is
only safe if the underlying communication is synchronized:

- **PyMeasure instruments**: matr1x applies the thread-safety patch
  (`matr1x.core.pymeasure_threading_fix`) to `pymeasure.instruments.
  Instrument` on import. Its per-instance reentrant lock makes
  individual
  `write`/`read`/`ask` calls and property accessors atomic across
  threads. Prefer that patch over writing your own locks around
  PyMeasure calls, and use `with dev.atomic_operation():` to group
  several low-level calls that must stay together (e.g. a `write`
  followed by `check_set_errors`).
- **`VisaDevice` family** (`matr1x.devices.visadevice`): `query`,
  `read`, `write` are already wrapped in `@synchronized` (see
  `sharedlock`).
- **Anything else** (custom devices, or compound read-modify-write
  sequences that must not interleave with another thread): protect them
  with a `threading.Lock` (or `RLock`) shared by every accessor, and
  reuse the same lock in `refresh` as well. If you take a lock, use it
  in *every* access path, including `refresh` and the `panic`/
  `unpanic` handlers; a lock that only guards the setter does not
  protect against the refresh thread.

#### 3. Keep the refresh worker out of the GUI event loop

`refresh()` runs on its own worker thread, so it must **never touch
widgets directly**. It may only set `self[...].value` (which marshals
the update to the GUI thread via the Qt signal). See the `refresh` row
in table 4.2. Do not block the refresh thread on GUI operations or vice
versa; use `QTimer.singleShot` for deferred UI-side work.

#### 4. Read setpoint values thread-safely

`self[...].getGUIvalue()` is thread-safe when called from the SCPI set
function/`write`: it reads the widget directly on the GUI thread and
falls back to a cached value otherwise (see `getGUIvalue`). Do not read
Qt widgets from the SCPI server thread yourself; always go through
`getGUIvalue`.

#### Checklist

- [ ] Every device is accessed through a lock (or the built-in
      PyMeasure/VisaDevice synchronization) from **all** entry points.
- [ ] `get` commands return buffered `self[...].value` unless there is a
      specific reason to read hardware.
- [ ] A multi-call atomic sequence uses `atomic_operation()` (PyMeasure)
      or one shared lock.
- [ ] `refresh` sets only `self[...].value`, never widgets.
- [ ] Device attributes that are read by `refresh` and written by a
      `set` use the same lock on both sides.

---

## 5. Concrete files

### 5.1 `matr1x/control/control_dummy.py` (reference example)

| Class                   | Role                                                                                                                                                                                                 |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `exampleDict(GuiDict)`  | Full-featured demo: combobox/lineedit/progressbar/checkbox/toggle rows, `MethodBundle`s (`color_bar`, `one_decimal`), custom menu action, `panic`/`unpanic`, `@catchEmitError` slots, `polling_cmd`. |
| `exampleDict2(GuiDict)` | Demo of a fast refresh (`refresh_period = 0.1`), `allow_disabling = True`, trend tooltip via `linear_trend`, hidden info row.                                                                        |
| `main()`                | `control_main("dummy", ControlWindow, guidicts=(exampleDict, exampleDict2), extra_cmds=common_commands, port=8897)`.                                                                                 |

## 6. Debugging

All commands below assume the current directory is the matr1x installation
(pkg-root). If you work in a user workspace instead, use the Python command
recorded in the workspace's `AGENTS.md` (i.e. `uv run --project <pkgroot>
python ...`) in place of `uv run python`.

Determine the log folder via

`uv run python -c "from matr1x.core.config import logfolder;print(logfolder)"`

### 6.1 Offscreen smoke test

`main()` creates its own `QApplication` (`MApplication`) and owns the event
loop, so the reliable smoke test is to run it in a subprocess, let it run for
a while, then terminate it. Use the Python recipe below so it works
identically on Linux, macOS and Windows (no shell-specific `&`, `timeout`
or `kill`):

```sh
uv run python -c "
import os, subprocess, sys, time
env = dict(os.environ, QT_QPA_PLATFORM='offscreen')
proc = subprocess.Popen(
    [sys.executable, '-c',
     'from matr1x.control import control_dummy; control_dummy.main()'],
    env=env)
time.sleep(20)
proc.terminate()
proc.wait(timeout=10)
"
```

`sys.executable` is the venv interpreter, so the subprocess runs in the same
environment as the outer `uv run`. Then check:

- The newest log in the log folder: "Control window '<name>' starting" should
  be followed by a clean shutdown ("closed by user" / "Exiting GUI") with no
  "failed to start" traceback. The log folder is the best source for startup
  bugs.
- The subprocess stdout/stderr for Qt warnings (add a `stdout=`/`stderr=`
  file handle to the `Popen` call if you want to capture them).

Gotchas:

- Do **not** construct `ControlWindow` yourself for testing: its first
  positional argument `name` is required
  (`ControlWindow(name, guidicts=..., extra_cmds=...)`) and you would miss the
  wiring `control_main` performs (lock file, error handler, SCPI server).
- Do **not** pre-create a `QApplication` or patch `QApplication.exec` to
  auto-quit; `main()` manages the application itself.
- The lock file is PID-based, so terminating the process is fine; the next
  start detects the stale lock and continues.
- Use `uv run python` (not `python3`), which resolves on all platforms.

### 6.2 SCPI query test (read-only)

While the control is running (e.g. during the 6.1 smoke test, offscreen or
visible), verify that the SCPI server answers by querying it over TCP.
**Only send get commands (the ones ending in `?`)** — the test must be
strictly read-only and must never send a set command, so it cannot change
the state of the control or of any instrument behind it. A good set of
queries is `*IDN?` plus the `Get` commands of the control's `cmds` dicts.

```sh
uv run python -c "
import socket

def query(cmd, port):
    with socket.create_connection(('127.0.0.1', port), timeout=10) as s:
        s.sendall((cmd + '\n').encode())
        chunks = []
        while True:
            data = s.recv(4096)
            if not data:
                break
            chunks.append(data)
            if b'\n' in b''.join(chunks):
                break
    return b''.join(chunks).decode().strip()

port = 8897  # the port passed to control_main in main()
print(query('*IDN?', port))
print(query(':v1?', port))  # replace with the control's Get commands
"
```

Notes:

- Get commands are answered with the value followed by a newline, which the
  loop above waits for. Set commands only send a single ACK byte (`\x06`)
  and no newline — another reason to keep the test read-only.
- The server lowercases every received line, so a query is case-insensitive,
  but any value you would send gets lowercased as well (e.g. string set
  values must be matched case-insensitively by the control).
- One connection per command is fine for a manual test; the server handles
  them sequentially.

### 6.3 Interactive run (mandatory final step)

Once the offscreen startup is clean, start the control visibly via its
`main()` function:

`uv run python -c "from matr1x.control import control_<name>; control_<name>.main()"`

and let the user interact with it: click buttons, toggle panels, trigger
panic. This catches runtime errors that never appear at startup. The control
is only finished after the user has exercised it without errors.

Site created with Great Docs.