# qet-mcp — a Model Context Protocol server for QElectroTech projects A small stdio MCP server that lets an AI assistant read and verify QElectroTech projects: what is in a project, what an edit actually changed, and what a whole corpus of projects contains. It has **no third-party dependencies** — Python 3.9+ and the standard library only. The MCP SDK is not required. ## Why Verifying a change by screenshot is unreliable, and this tool exists because that unreliability produced two wrong conclusions in one review session: - A drag of a multi-element selection *looked* like it had left the symbols behind and detached their labels. Diffing the saved file showed all four elements had moved by an identical `(0, -80)` and **no label had moved at all**. A bug report was one step away from being filed. - An "Apply" button *looked* like it did nothing. It was disabled, because a required field was empty. Both times the pixels misled and the model told the truth. So the tools here read the model. ## Tools | Tool | What it answers | |---|---| | `qet_project_info` | title, format version, folios with their uuids, element and conductor counts | | `qet_elements` | placed elements: uuid, type, position, label, information bag | | `qet_conductors` | conductors and their documentation fields; filter by attribute | | `qet_items` | free texts, shapes, pictures, tables and symbol text fields, each with its uuid | | `qet_diff` | **what an edit actually changed** — element moves, adds, removes, relabels; conductor changes; and folio fields, texts, shapes, images, symbol text fields and terminal strips | | `qet_scan` | sweep a directory of projects, counting nodes carrying an attribute | | `qet_element_info` | a `.elmt`: translated names, terminals, info fields, part counts | | `qet_export` | run a headless export (pdf, png, svg, dxf, bom, cables, wires, wiring, nets, links, info) | | `qet_edit` | **change a project** — place, move, rotate, label, wire, number, cross-reference, add text, shapes and images, restyle a symbol's text fields, delete; then diff the result | | `qet_element_build` | **author a `.elmt`** — draw a new symbol, with terminals to wire it by | | `qet_project_new` | **start from nothing** — an empty project with a title and folios | | `qet_element_search` | **find a symbol** in a collection by name (any language), type or terminal count | | `qet_check` | **design-rule checks** — duplicate labels, unlabelled masters, unnumbered conductors, empty folios, terminals with more than four wires, folio reports with several wires | | `qet_layout_check` | **does the drawing read well?** — a 0–100 score; wires that jog because two symbols are a few pixels out of line, symbols off the grid, wires through symbols, overlaps, crossings; and the moves that fix them, ready for `qet_edit` | | `qet_query` | **ask the project database** — read-only SQL over the views and tables | | `qet_about` | **start here** — where QElectroTech keeps things, what is switched on, the stored scripts, the calls a script can make (from `qet-assistant.json`) | | `qet_script_api` | **what a script can call** — every `qet.*` call of this build, and the header that makes a script a button | | `qet_script_test` | **try a script** on a copy of a project: what it would change, what it logged, its errors | | `qet_script_install` | **make a button** — store a script (and an SVG icon) where QElectroTech shows it in Project > Scripts and the Scripts toolbar | | `qet_script_list`, `qet_script_read`, `qet_script_remove` | the stored scripts: list, read one to change it, delete one | | `qet_recording_list`, `qet_recording_read`, `qet_recording_check`, `qet_recording_remove` | **macro recordings** — what you did by hand, and whether a script does the same | `qet_export` and `qet_edit` launch QElectroTech. Everything else parses the file directly, which is faster, needs no display, and cannot be confused by a dialog. ## Running it ```bash # list the tools and exit misc/qet-mcp/qet_mcp.py --list # speak MCP on stdin/stdout misc/qet-mcp/qet_mcp.py # run one tool and exit, no MCP client needed misc/qet-mcp/qet_mcp.py --call qet_project_info '{"path": "drawing.qet"}' ``` Register it with an MCP client, for example: ```json { "mcpServers": { "qet": { "command": "python3", "args": ["/path/to/qelectrotech/misc/qet-mcp/qet_mcp.py"], "env": { "QET_MCP_WORKSPACE": "/home/you/drawings", "QET_BINARY": "/usr/bin/qelectrotech", "QET_ENABLE_SCRIPTING": "1" } } } } ``` `QET_BINARY` is the QElectroTech the tools launch. Leave it out when `qelectrotech` is on your `PATH`, or when the server is installed with QElectroTech (as `/share/qelectrotech/mcp/qet_mcp.py`, which also finds the installed element collection). ## Installed with QElectroTech QElectroTech's packages install the server next to the program, and from there it finds that QElectroTech and its element collection by itself: | Package | Server | Finds | |---|---|---| | `make install`, Linux distributions | `/share/qelectrotech/mcp/qet_mcp.py` | `/bin/qelectrotech`, `/share/qelectrotech/elements` | | Windows installer, MSI, portable folder | `\mcp\qet_mcp.py` (the "AI assistant (MCP)" component) | `\bin\QElectroTech.exe`, `\elements` | So a client configuration needs only the path to the server and the workspace. On Windows, the installer also offers **Python for the AI assistant** (unticked by default; always in the portable folder and the MSI): Python from python.org in `\mcp\python`, for anyone without a Python of their own. Then: ```json "command": "C:\\Program Files\\QElectroTech\\mcp\\python\\python.exe", "args": ["C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"] ``` Snap and flatpak install it too. Their QElectroTech is built to run inside the package's sandbox, and starting it from the server has not been tested: the tools that read a file work, exports and edits may not. If they fail, point `QET_BINARY` at a QElectroTech installed another way. ## Using it from the Claude app A web chat in a browser cannot start a program on your computer, so it cannot run this server. The Claude desktop app for Windows and macOS can, and it uses the same account as the website. 1. Install Python 3.9 or later. Nothing else is needed. 2. In the desktop app, open **Settings → Developer → Edit Config**. This opens `claude_desktop_config.json`. 3. Add the server, with your own paths: ```json { "mcpServers": { "qet": { "command": "python", "args": ["C:\\path\\to\\qelectrotech\\misc\\qet-mcp\\qet_mcp.py"], "env": { "QET_MCP_WORKSPACE": "C:\\Users\\you\\Documents\\drawings", "QET_ENABLE_SCRIPTING": "1" } } } } ``` On macOS use `python3` and ordinary `/` paths. In JSON every `\` in a Windows path is written `\\`. 4. Quit the app completely and start it again. The tools appear under the chat box's tools menu. 5. If `qelectrotech` is not on your `PATH`, add `"QET_BINARY"` to the `env` block with the full path to the executable (`C:\\Program Files\\...\\qelectrotech.exe`, written with `\\`). Only files under `QET_MCP_WORKSPACE` can be read or written (see [What the server is allowed to touch](#what-the-server-is-allowed-to-touch)). Leave out `QET_ENABLE_SCRIPTING` if you do not want the assistant to edit projects; see the next section for what that switches off. The test suite runs on Linux. The server uses nothing platform-specific, but it has not yet been tested on Windows or macOS. ### With only a browser If your web chat can run Python (on claude.ai, code execution), upload `qet_mcp.py` together with your project and ask the assistant to use `--call`: ```bash python3 qet_mcp.py --call qet_elements '{"path": "drawing.qet"}' echo '{"path": "drawing.qet"}' | python3 qet_mcp.py --call qet_check - ``` Pass long arguments, such as a large `qet_edit` operation list, on stdin with `-`: Windows refuses a command line over 32,767 characters (`WinError 206`). It prints the tool's JSON result and exits 0, or 1 if the tool reported an error, or 2 if the call itself was malformed. The workspace rule applies as it does in a server. The sandbox has no QElectroTech in it, so only the tools that read files work there: `qet_project_info`, `qet_elements`, `qet_conductors`, `qet_items`, `qet_diff`, `qet_scan`, `qet_element_info`, `qet_element_search` and `qet_element_build`. ## Some tools need scripting switched on A QElectroTech with JavaScript scripting switched off refuses `--run`, and off is the default from [#984](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/984) onwards. These tools drive it that way, or store a script that runs when clicked, and stop working until it is turned on: | | | |---|---| | need `QET_ENABLE_SCRIPTING=1` | `qet_query`, `qet_continuity`, `qet_check`, `qet_layout_check`, `qet_project_new`, `qet_edit`, `qet_script_api`, `qet_script_test`, `qet_script_install`, `qet_script_remove` | | unaffected | everything else — they read the `.qet` directly, or, in `qet_export`'s case, use a plain CLI flag | The variable goes in the environment this server is started in, which for an MCP client is the `env` block above; the server passes its environment straight through to QElectroTech. It does not set the variable itself, on purpose — a switch a program turns on for itself is not a switch. Whoever configured this server and pointed it at a QElectroTech binary made that choice, and their interactive QElectroTech keeps whatever its own setting says. Without it, those come back `"ok": false` with a `hint` naming the variable. Older builds, from before the setting existed, need nothing. ## What the server is allowed to touch Every path in a tool call is chosen by the model, so without a policy this server would be a read/write primitive for anything the operating system lets the process reach: read any project on the disk, export one somewhere else, overwrite an unrelated file, embed an arbitrary local image or PDF. So **data paths are confined to a workspace**: | | | |---|---| | `QET_MCP_WORKSPACE` | the directories tool calls may read and write, separated by `:` (`;` on Windows) | | unset | the directory the server was started in | | `QET_MCP_ALLOW_ANY_PATH=1` | turns the check off entirely | Set the workspace to the folder your drawings live in. A path outside it is refused with an error naming what was allowed; symlinks are resolved first, so a link planted inside the workspace is judged by where it points. **The client does not choose what program runs.** The tools that launch QElectroTech use the one the server found (`QET_BINARY`, the install it ships in, or `PATH`). A call may still name `binary`, but only as that same file or one listed by whoever configured the server: | | | |---|---| | `QET_BINARY` | the QElectroTech to launch | | `QET_MCP_BINARIES` | other executables a call may name, separated like `QET_MCP_WORKSPACE` (for comparing two builds) | | `QET_MCP_ALLOW_ANY_BINARY=1` | turns the check off: a call can then run any program | | `QET_MCP_ELEMENTS` | element collections a call may name as `elements_dir` besides the workspace and the installed one | Anything else is refused, even a file inside the workspace: being there makes it readable, not runnable. Before this rule any executable a call named was run, with the call's own paths as arguments, so text inside a project could steer an assistant into starting another program. `QET_MCP_ALLOW_ANY_PATH=1` is equivalent to granting the client local filesystem access with this process's privileges. It exists so that is a deliberate choice rather than the default. **Nothing is overwritten unasked.** `qet_export`, `qet_edit`, `qet_project_new` and `qet_element_build` refuse an `output` that already exists unless the call passes `"overwrite": true`. Replacing a file is the one step this server cannot undo, so it is the one step it will not take on its own. The confinement is applied where tool arguments enter the server, not inside each tool. Importing `qet_mcp` and calling `tool_export()` from your own Python is not confined and is not meant to be — that is your code calling a library, and you already chose the paths. ## What QElectroTech tells the server: `qet-assistant.json` Each time an editor window opens, and whenever its stored scripts, settings or live channel change, QElectroTech writes `qet-assistant.json` in its standard data folder (`~/.local/share/QElectroTech/QElectroTech/` on Linux, `%APPDATA%\QElectroTech\QElectroTech\` on Windows). It names every folder actually in use, even when QElectroTech was started with `--data-dir`, which features are on, every call a script can make, and the stored scripts. The server reads it instead of guessing; `qet_about` shows it. Set `QET_MCP_INFO_FILE` to read it from somewhere else. The server also sends the assistant a short note at first contact: the two ways of working (files, or live), the usual order of tools, and to start with `qet_about`. ## Script buttons QElectroTech turns every `.js` file in its scripts folder that starts with a `// ==QETScript==` header into a command with an icon: in Project > Scripts, on the Scripts toolbar, in command search and in the shortcut bar. A person can write that file by hand; an assistant uses the tools above. Both end with the same file, and an open QElectroTech picks it up without a restart. ```js // ==QETScript== // @name Add revision note // @icon add-revision-note.svg // @tooltip Puts a "Rev A" note on the folio on screen // @shortcut Ctrl+Alt+R // @context canvas // ==/QETScript== qet.addText(qet.currentFolio(), "Rev A", 40, 40); ``` The usual round: `qet_script_api` for the calls, `qet_script_test` on a project until the diff is what was wanted, then `qet_script_install` with `test_project` set, so a script that fails is not stored. The assistant never presses the button: the user does, and one Ctrl+Z undoes the run. | | | |---|---| | folder | QElectroTech's data folder + `/scripts`: `~/.local/share/QElectroTech/QElectroTech/scripts` on Linux, `%APPDATA%\QElectroTech\QElectroTech\scripts` on Windows, `~/Library/Application Support/QElectroTech/QElectroTech/scripts` on macOS | | `QET_MCP_SCRIPTS_DIR` | another folder, for a QElectroTech started with `--data-dir` | The folder is chosen by the server, never by a call, and a script's id becomes its file name only if it is `a-z`, `0-9`, `-` and `_`. Storing or removing a script needs `QET_ENABLE_SCRIPTING=1` like an edit does: a stored script runs with the user's rights when they click it. ## Macro recordings: from something done by hand to a button In QElectroTech, Project > Scripts > Record a macro records what you do on a project until you click it again. It saves the project before and after, and each step from the undo history with the folio after it. At Stop it offers to copy a ready-made request; paste that into the assistant. | | | |---|---| | `qet_recording_list` | the recordings, newest first | | `qet_recording_read` | one recording: each step as structured changes, and the overall change | | `qet_recording_check` | run a script on the "before" project, from the same folio and selection, and say whether the result **matches** the "after" project, or what differs | | `qet_recording_remove` | delete one | The usual round: read the recording, write a script that does the same in general (on the selected elements, say, not on these exact ones), `qet_recording_check` it until it matches, then `qet_script_install` it. ## Live mode: working in the QElectroTech you have open Every tool above works on files, with no QElectroTech window involved. The three `qet_live_*` tools instead act on the project open in **your** QElectroTech, in front of you, so you can watch, stop or undo: | | | |---|---| | `qet_live_status` | what is on screen: project, folio, selection, last undo step, stored scripts | | `qet_live_run_script` | run script text on the open project: one undo step named "Assistant: …" | | `qet_live_run_stored` | press a stored script's button | | `qet_live_command` | an editor command from an allow-list that opens no dialog: selection, zoom, rotate, snap, group, reset wires | | `qet_live_show_folio` | show another folio | | `qet_live_undo_last` | undo the newest step, only if the assistant made it | | `qet_live_screenshot` | a picture of the folio on screen, as an MCP image, cropped to the folio | A script the assistant writes on the spot is shown to you first, with *Run*, *Decline* or *Always this session*; the Assistant panel lists everything it did. QElectroTech only listens when three things are true: 1. the server has `QET_ENABLE_SCRIPTING=1`, as for editing; 2. in QElectroTech, Settings > Configure QElectroTech > General, on the Projects tab, "Allow an AI assistant to act on the open project (live mode)" is ticked (off by default; in French, Configurer QElectroTech > Général > Projets); 3. at this start, you answered *Continue* to the warning QElectroTech shows every time it starts with that setting on. While it listens, the status bar says so and shows the assistant's last action, with a *Stop* button that closes the channel for the rest of the session. Each action is one Ctrl+Z. A script's `qet.showMessage()` is logged instead of opening a box nobody asked for. The channel is a local socket only your user can open. QElectroTech puts its name and a random token in the `live` part of `qet-assistant.json`, and clears it when the channel closes; `qet_about` says whether one is open but never shows the token. ## Worked examples **What did that edit change?** ```json {"name": "qet_diff", "arguments": {"before": "a.qet", "after": "b.qet"}} ``` ```json "elements": { "moved_count": 4, "distinct_move_deltas": [[0.0, -80.0]], "relabelled": [], "info_changed": [] } ``` Four elements moved by one uniform delta; nothing was relabelled. That is the answer a screenshot gave wrongly. **Tidy a drawing: straight wires, symbols in line** A wire is straight only when its two terminals are exactly in line. A symbol is placed by its origin and its terminals sit at an offset from it, so symbols placed "under each other" by eye are often a few pixels apart and the wire jogs. After drawing: ```json {"name": "qet_layout_check", "arguments": {"project": "drawn.qet"}} ``` ```json {"ok": true, "style": "iec", "score": 40, "summary": {"wires": 4, "straight_wires": 0, "avoidable_bends": 4, "off_grid": 0, ...}, "findings": [{"rule": "avoidable_bend", "folio": 1, "offset": 3.0, ...}], "fixes": [{"op": "move_element", "folio": 0, "element": "{...}", "dx": -3.0, "dy": 0.0}, ...]} ``` Pass `fixes` as they are, all in one call, to `qet_edit`, then check again; the same drawing then scores 100. The moves are planned together: a wire that is already straight pins its two symbols, a move never puts a symbol on another one or across another wire, and symbols lined up with each other go onto the grid together. A jog no move can fix (two symbols whose terminals are not spaced alike) is reported with `"conflict": true`. `style` is `iec` (current paths as columns, wires mostly vertical), `nfpa` (ladder rungs as rows, wires mostly horizontal) or `auto`, which goes by the drawing. The check is read-only. On a QElectroTech build without `conductorPath()`, a wire whose two terminals both carry other wires cannot be read; the answer names those in `unread_wires` and leaves them out of the score. **Draw with straight wires from the start** A wire is straight only when its two terminals are exactly in line, and a symbol is placed by its origin, not by its terminals. `place_element` does the arithmetic: it adds a symbol with one of its terminals in line with another symbol's, `gap` pixels away (40 by default), on the side that terminal faces. ```json {"op": "add_element", "folio": 0, "path": "common://…/borne_2.elmt", "x": 100, "y": 100, "id": "x1"}, {"op": "place_element", "folio": 0, "path": "common://…/contact.elmt", "terminal": 0, "next_to": "$x1", "next_to_terminal": 2, "id": "k1"}, {"op": "add_conductor", "folio": 0, "from": "$x1", "from_terminal": 2, "to": "$k1", "to_terminal": 0} ``` For a column of current paths (IEC) the next symbol goes below; for a ladder rung (NFPA) to the right, with the symbols rotated so their terminals face along the rung. If the named terminal faces the wrong way, the op says so in its `note`. `align_terminal` lines up a symbol that is already placed; `align_elements` and `distribute_elements` line up and space whole rows or columns. `place_element` and `align_terminal` need a QElectroTech with `qet.terminalPosition()`. **Draw something, and check it landed** ```json {"name": "qet_edit", "arguments": { "project": "in.qet", "output": "out.qet", "elements_dir": "/path/to/qelectrotech/elements", "operations": [ {"op": "add_folio", "id": "f"}, {"op": "set_folio_title", "folio": "$f", "title": "Starter"}, {"op": "add_element", "id": "k1", "folio": "$f", "path": "common://.../coil.elmt", "x": 100, "y": 100}, {"op": "add_element", "id": "k2", "folio": "$f", "path": "common://.../coil.elmt", "x": 320, "y": 100}, {"op": "add_conductor", "folio": "$f", "from": "$k1", "from_terminal": 0, "to": "$k2", "to_terminal": 0}, {"op": "set_conductor", "folio": "$f", "element": "$k1", "terminal": 0, "property": "num", "value": "W7"}, {"op": "set_label", "folio": "$f", "element": "$k1", "label": "KM1"} ]}} ``` An op that creates something takes an `"id"`; later ops name it as `"$id"`. A `"folio"` given as a number counts **from 0**, while `qet_elements` and `qet_project_info` number folios from 1 as the application does: the folio they call 1 is `"folio": 0` here. An op that fails because of this says which index to use. Terminals are addressed by index — top to bottom, then left to right, **not** the order the `.elmt` lists them. `qet_element_info` and `qet_element_search` both report that index order. The answer carries a per-operation result *and* a `qet_diff`, because "addConductor → true" says the call was accepted, not that the file came out right: ```json "diff": {"elements": {"before": 11, "after": 13, "added": ["{0aa3…}", "{6f63…}"]}, "conductors": {"before": 47, "after": 48, "added": ["4:{0aa3…}/{2904…}--{6f63…}/{2904…}"], "removed": []}} ``` **Draw a symbol that does not exist yet** ```json {"name": "qet_element_build", "arguments": { "output": "/path/to/collection/99_custom/my_resistor.elmt", "names": {"en": "Test resistor", "fr": "Résistance de test"}, "parts": [ {"type": "rect", "x": -10, "y": -20, "width": 20, "height": 40}, {"type": "line", "x1": 0, "y1": -30, "x2": 0, "y2": -20}, {"type": "line", "x1": 0, "y1": 20, "x2": 0, "y2": 30}, {"type": "text", "x": 14, "y": -4, "text": "R"} ], "terminals": [{"x": 0, "y": -30, "orientation": "n", "name": "1"}, {"x": 0, "y": 30, "orientation": "s", "name": "2"}]}} ``` Then place it with `qet_edit` like any catalogue element. Unlike a project, a `.elmt` is not rewritten by QElectroTech on a round trip, so generating one here is safe in a way that generating a `.qet` would not be — there is no `toXml()` waiting to drop what this writer did not know to emit. **Ask a question the XML cannot answer** ```json {"name": "qet_query", "arguments": { "project": "industrial.qet", "sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view WHERE label <> '' GROUP BY label HAVING n > 1 ORDER BY n DESC"}} ``` ```json "rows": [{"label": "V6", "n": 7}, {"label": "V5", "n": 6}, {"label": "V4", "n": 6}] ``` Duplicate element labels in a shipped example — a design-rule question, answered by the database that already knew it. **How much of a corpus uses a field?** ```json {"name": "qet_scan", "arguments": {"directory": "examples", "tag": "conductor", "attribute": "cable"}} ``` ```json { "files": 24, "total": 3190, "non_empty": 0, "distinct_values": [] } ``` Across the shipped examples: 3190 conductors, not one with a cable value. ## Testing ```bash python3 test_qet_mcp.py # unit + protocol, no QElectroTech needed QET_BINARY=/path/to/qelectrotech \ QET_ELEMENTS=/path/to/qelectrotech/elements \ QET_EXAMPLES=/path/to/qelectrotech/examples \ QET_ENABLE_SCRIPTING=1 \ python3 test_qet_mcp.py # everything ``` `QET_ENABLE_SCRIPTING=1` matters from #984 onwards: without it the integration tests that drive QElectroTech through a script all fail, and they fail as "the edit did nothing" rather than as "scripting is off", which reads like a regression in the thing under test. 176 tests in three layers: unit (validation, script generation, the terminal order rule, the diff, the part schema), the real stdio transport, and integration against a built QElectroTech. Several exist because the behaviour they pin was once wrong and looked right, and say so in their docstrings. To check the suite itself rather than trust it, each of those bugs was reintroduced in turn and the suite confirmed to fail: ten in the Python, plus the hang guard on `addConductor` and the database refresh in `ConductorCreator` in the C++. ## Notes and limits - **Two ways of numbering folios.** Tools that read the file — `qet_project_info`, `qet_elements`, `qet_conductors`, `qet_diff` — number folios from 1, as the application does. Tools that pass a folio to QElectroTech's scripting API — `qet_edit` and `qet_continuity` — take an index counted from 0, so the folio `qet_elements` calls 1 is `0` there. `qet_continuity` refuses an index with no folio instead of reporting it clean, and each of its findings carries both `folio` (the index) and `folio_number` (counted from 1). - **The project database is reachable now, through `qet_query`.** It was not when this server was written, which is why every other structural tool here re-derives its answer from the XML. Prefer the views — `element_nomenclature_view`, `project_summary_view`, `wiring_list_view` — which exist to be queried; the underlying tables are how the cache is arranged today and a column may move. Call `qet_query` with no `sql` to list both. Only `SELECT` and `WITH` are accepted, which is the rule QElectroTech applies to its own custom-query box, not one invented here. An empty result and a failed query are told apart: `row_count` 0 with no `error` means nothing matched, and a typo'd column name says so. - **Texts, shapes and images are in the database too**, one row each in `drawing_item_view` (`uuid`, `kind`, `folio`, position and size, and a `description`: the shape type or the text). The uuid is the one saved in the file, and every `qet_edit` op that takes a text, shape or image `index` also takes that uuid, which does not shift the way an index does. `qet_element_build` gives every part of a symbol a uuid as well, returned in `part_uuids`; `qet_element_info` lists them in `part_list`. - **A folio can be named by its uuid** wherever an op takes `folio` or `to_folio`; `qet_project_info` lists each folio's. It still names the same folio after an earlier op in the run adds, inserts or removes one, where an index would shift. A folio saved without a uuid shows it empty: QElectroTech gives it one on load and writes it on the next save, so it appears after a first `qet_edit`. Needs `qet.folioIndex()` in the build. The `"$id"` of an `add_folio` or `insert_folio` works the same way: it keeps naming that folio after a later `insert_folio` or `remove_folio` in the same run (on a build without `qet.folioUuid()`, it is the index the folio had when it was made, as before). - **A conductor can be named by its uuid** (`qet_conductors` reports it): `set_conductor`, `move_conductor_segment` and `delete_conductor` take `"conductor": "{uuid}"` in place of `element` + `terminal`, which works where two conductors meet at a terminal. It is turned at run time into an end whose terminal carries only that conductor; where both of its ends are shared the op fails and its `note` says why. Needs `qet.conductorEnds()` in the build. A uuid names one wire, but `set_conductor` still changes the whole potential, the same as by terminal. A project saved before conductors carried a uuid has none in the file until it is saved once: since #1107 QElectroTech works one out from the wire's two ends on load and writes it on the next save, so it appears after a first `qet_edit`. - **Wires can be routed around symbols.** By default a new conductor gets QElectroTech's own two or three straight segments, which run through whatever symbol or wire lies between the two terminals. Give `add_conductor` `"route": "avoid"` to redraw it around the symbols instead, or use `route_conductor` (addressed like `move_conductor_segment`, by `element` + `terminal` or by `"conductor": "{uuid}"`) to redraw one already drawn. The route leaves and enters each terminal in its own direction, runs on the folio grid, stays inside the folio's border, and is the cheapest found by a search that charges for length, for each bend and, less heavily, for running along or crossing another wire. Obstacles are each symbol's own rectangle plus half a grid step; texts, images, shapes and tables are not obstacles. Running along another wire costs more but is not forbidden, so where there is no other way two wires can end up drawn on top of each other. The path is saved as a hand-edited one, so it survives a reload and one undo puts the default back. Where no route exists, the wire keeps its path: `route_conductor` returns `"no-route"` and both ops say so in `note` -- it is not a failure, and the run goes on. Like a hand-edited path, it is stretched rather than rerouted when a symbol is moved afterwards; route again after moving things. Needs `qet.routeConductor()` / `qet.routeConductorBetween()` in the build, and only an edit that routes requires them. A symbol drawn around either end's own symbol (a cabinet made as one element) is not an obstacle, so a wire between two symbols inside one is routed inside it. Two terminals facing each other on one line, with nothing between them, are joined by a straight line however close they are. A terminal pointing straight into another symbol has no route, rather than one through that symbol. - **A terminal can be named by its uuid**: `terminal`, `from_terminal` and `to_terminal` take the terminal's uuid (as `qet_element_info` lists it) in place of its index, on the op's own element (for `add_conductor`, on that end's element). It is turned at run time into the index the call takes; if the element has no terminal with it the op fails and its `note` says so. Unlike the index, which is a sort by position, it is defined between two terminals at the same point. Needs `qet.terminalIndex()` in the build. A symbol file saved without terminal uuids lists them empty; QElectroTech gives the terminals of every project's copy of it a uuid on opening (#1118), written on the next save. - **A folio can be sized for a sheet of paper**: `set_folio_border` with `"property": "preset"` and a value such as `"tabloid-landscape"` (A0–A5, letter, legal, tabloid or ledger, each `-portrait` or `-landscape`) picks the column and row counts and sizes that fill the sheet best without going over it, as one undo step, keeping each size as near the folio's current one as it can. The title block and headers are measured from the folio, not assumed, so it holds for any template on either edge. Sizes stay whole numbers, because the folio properties panel edits them in whole pixels and would round a fraction off the first time it was opened; so the page can come out up to 0.75 pt short of the sheet a side. The op's `note` says what it chose and the size of the frame a PDF export measures, e.g. `23 columns of 70, 12 rows of 82; frame 1223.25 x 791.25 pt` for tabloid landscape from a new folio. (The PDF export measures the frame and title block plus its one-pixel line, at 96 pixels an inch: 0.75 pt a pixel, and writes it on the standard sheet it is within 3 pt of.) Needs `qet.folioPresets()` in the build. - **The `wiring` export names unnamed terminals.** Most shipped symbols leave their terminals unnamed, so `from_terminal`/`to_terminal` are often empty. Each row also ends with `from_terminal_index`, `from_terminal_uuid`, `to_terminal_index` and `to_terminal_uuid`: the index `add_conductor` takes, and the uuid the `.qet` names the terminal by (as `qet_edit` accepts in place of the index). The index is empty for a terminal sharing its point with another, where the order is undefined; the uuid tells those apart. `wiring_list_view` carries the same four columns for `qet_query`. - **`"reproducible": true` makes a PDF comparable byte for byte.** Two exports of the same project otherwise differ in their dates and document id. The option sets `SOURCE_DATE_EPOCH` for the run ([reproducible-builds.org](https://reproducible-builds.org/specs/source-date-epoch/)): the PDF then carries that date in UTC, a document id derived from the project file, and its fonts in a fixed order. The date is `source_date_epoch` if given, else the server's own `SOURCE_DATE_EPOCH`, else 0 (1 January 1970). The result's `"reproducible"` is false, with a hint, when the QElectroTech build is too old to honour it. - **`qet_export` isolates its launch.** SingleApplication keys its socket on `applicationFilePath()`, so a second launch of the same binary path forwards its request to an already-running instance and returns *that* process's answer with no error. The tool copies the binary to a unique temporary path, gives it a private `HOME`, and runs it on the offscreen platform. A symlink would not work: `applicationFilePath()` resolves it back to the real path. - **The CLI matches its flags exactly.** `--export-bom out.csv` is the supported form; `--export-bom=out.csv` is not recognised as an export at all, so the application starts its interface instead and a headless run hangs. The tool uses the positional form. - **Conductor identity is the hard part of `qet_diff`.** A conductor names its ends with `terminal1`/`terminal2`, and the project format has two schemes: folio-scoped integer ids in older files, terminal-definition uuids plus `element1`/`element2` in newer ones. The integer ids are **renumbered on every save**, so keying on them — which this tool did at first — made all 47 conductors of an untouched folio read as removed and re-added the moment the other side had been through QElectroTech, which is exactly what `qet_edit` produces. They are now keyed by owning element uuid plus terminal, which is stable across a save: measured at 0 colliding keys over 3190 conductors in the 24 shipped examples, and 0 churn on a no-op edit. Where an element predates persisted uuids the end cannot be resolved and keeps a `#`-marked unstable key; the diff then reports `unstable_keys` and says so rather than pretending to be comparable. - **Texts, shapes and images are keyed by uuid** when every one on both sides has one, so an edit or a move reads as a change to that item. A file saved before they carried a uuid has none; for such a pair (including a legacy file against its first re-save) that kind falls back to position, where an edited text reads as removed plus added and a move as a removal plus an addition. Each section says which it used in `keyed_by`. The folio `version` attribute is left out of the comparison on purpose: QElectroTech rewrites it on every save, and including it made every folio of any re-saved project look edited. - **Elements** written before persisted uuids fall back to a positional key, which makes a move in such a file read as a remove plus an add. - **`qet_edit` needs a build whose scripting API carries the drawing verbs.** Against an older one it reports exactly which methods are missing and changes nothing. `addElement` and the move/delete verbs shipped with the scripting API; `addConductor`, `rotateElement`, `setElementLabel`, `setElementInfo` and `setFolioTitle` are newer. - **`elements_dir` is not optional for `common://` paths** unless the server is installed with QElectroTech, which fills it in. The sandboxed run has its own empty HOME, so QElectroTech falls back to the compiled-in collection path, which on a machine that never ran `make install` does not exist. The only symptom is `addElement` reporting that a file plainly present "does not resolve to an element". An absolute `.elmt` path works without it. On Windows and macOS, `elements_dir` needs a QElectroTech that reads `QET_SETTINGS_DIR` (#1178): an older one keeps its settings in the registry or the system preferences, never sees the path written for the run, and uses the collection it was installed with. - **`set_conductor` changes the whole potential, not one segment.** That is what the application does — a wire number describes a potential — so name a terminal carrying exactly one conductor and the change reaches every conductor electrically joined to it. A terminal several conductors meet at names none of them and is refused, so address a potential from one of its leaves. Property names are the file's own, so `qet_conductors` reads back exactly what was set. - **`set_conductor_default` sets a folio's conductor defaults**, the Conductors tab of Folio properties: `onetextperfolio` (`"true"` shows one wire number per potential on the folio), or any `set_conductor` property, which conductors drawn later on that folio start from. `"folio": -1` sets the project's defaults instead, which each folio added afterwards copies; it does not change existing folios. Like the dialogs, it is not on the undo stack. - **`link_elements` takes a folio for each end**, because a master and its slave are normally on different folios. Whether a pair may be linked is decided by QElectroTech's own `isLinkable()`, so a script cannot make a link the GUI would refuse. - **An element must live inside a collection to be placeable.** This is not about the path syntax: an absolute `.elmt` path works, but only if the file sits under a directory QElectroTech knows as a collection. Write it under the tree you pass as `elements_dir` and `qet_edit` can place it, by absolute path or as `common://…`; write it anywhere else and `add_element` reports only "does not resolve to an element". - **`qet_element_build` computes the `.elmt` size header, and checks it.** `width`/`height`/`hotspot_x`/`hotspot_y` relate to the drawing by a containment constraint, not a formula — the declared box runs from `(-hotspot_x, -hotspot_y)` to `(width - hotspot_x, height - hotspot_y)` and the drawing must fit inside it. The shipped collection shows authors picking their own margins (one element pads 2 units left and 3 right, another 8 and 2), so there is no convention to copy, only an invariant to satisfy. A drawing that escaped its box is the classic way a hand-written element renders clipped in the collection panel while looking fine in XML. - **QElectroTech interrupts a script at 30 s** of its own accord, separately from this tool's `timeout`. A very long operation list will hit that first. - **`qet_edit` never writes the input.** It saves to a separate file and diffs the two, so the original is always the thing the diff is against.