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-measurementsCodex / 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.mdOr 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.