Repository navigation
scripting
Read a project's model, export it, and edit its geometry from a script β headless for CI, or interactively against the diagram you have open.
Issue #162 asked for scripted automation: export derived files (PDF, BOM, cable lists) after every revision, and drive that from CI. The headless export flags (see CLI Reference) already covered the fixed, one-shot cases. This covers the rest: loops, conditionals, reading the model to decide what to do next, and β new in this PR β editing the diagram itself.
The engine is Qt's own QJSEngine, not an embedded Python interpreter. That was a deliberate choice, not the obvious one:
-
No new toolchain to package. QJSEngine ships in the
Qmlmodule of every Qt SDK QET already targets. Nothing new to install on Windows/macOS packaging, no interpreter version to pin, no GIL. -
The core classes reflect into scripts almost for free, since they are
already
QObjects β no hand-written binding layer to maintain. -
It is optional at build time, probed the same non-fatal way
QtPdfalready is. A QET build without theQmlmodule compiles identically; scripting is simply absent.
Scripting is off until you switch it on. A script runs with your rights:
it can read and change the project and write files. Tick Autoriser
l'exΓ©cution de scripts JavaScript (Allow running JavaScript scripts) in
Configurer QElectroTech > GΓ©nΓ©ral > Projets (Settings > General >
Projects), or, for a run with nobody to tick a box (CI, a batch job), set the
environment variable QET_ENABLE_SCRIPTING=1. With neither, running a script
from the editor first asks whether to turn scripting on, and --run stops
with exit code 3 and says how to turn it on.
qelectrotech --run script.js project.qetOpens the project, runs the script, exits. Exit codes match the rest of the
CLI: 0 success, 1 the script threw or the project failed to open, 2
called wrongly (missing arguments, file not found), 3 scripting is off.
#!/bin/bash
set -e
qelectrotech --run export_after_revision.js "$1"Projet β Scripts β ExΓ©cuter un script... (Project β Scripts β Run
script) opens a file picker (filtered to *.js) and runs the chosen script
against the currently open project. Useful for one-off macros you don't want
to wire into CI. The same menu holds stored scripts with their own button,
icon and shortcut β see Script buttons. A run from the editor is
one undo step, named Script : <name>, so one Ctrl+Z undoes the whole
script (PR #1219); headless --run keeps one step per call.
Every script sees one global, qet. Four groups of capability, each with a
different scope β read the boundary of each before assuming more:
qet.projectTitle() // -> "My Panel"
qet.filePath() // -> "/path/to/project.qet"
qet.folioCount() // -> 3
qet.folioTitle(0) // -> "Power"
qet.elementCount(folioIndex) // -> 42
qet.conductorCount(folioIndex) // -> 58Same numbers --info reports, available programmatically instead of parsed
from JSON.
Thin wrappers around the same --export-* machinery documented in
CLI Reference β same logic, same output, called from a
script instead of a flag:
qet.exportPdf(output, showTerminals = false)
qet.exportPng(outDir, showTerminals = false)
qet.exportSvg(outDir, showTerminals = false)
qet.exportDxf(outDir, showTerminals = false)
qet.exportCables(output)
qet.exportWires(output)
qet.exportBom(output, noSlaves = false, noJunctions = false)
qet.exportWiring(output)
qet.exportNets(output)
qet.exportLinks(output)
qet.exportInfo(output) // output may be "" for stdout
qet.setTitleBlock(output, ["revision=B", "date=today"])
qet.save(output) // output may be "" to save in placeAll return true/false.
Important, and worth reading twice: every export method above re-opens
the project fresh from its file on disk. They never see edits a script made
with the methods in the next section β only save() writes the project's
live, in-memory state. Editing then exporting means calling save() first:
qet.addElement(0, "embed://β¦/relay.elmt", 100, 100);
qet.save(""); // write the edit to disk
qet.exportPdf("out.pdf"); // now this sees itqet.addElement(folioIndex, locationPath, x, y) // -> uuid string, or "" on failure
qet.setElementPosition(folioIndex, elementUuid, x, y)
qet.moveElement(folioIndex, elementUuid, dx, dy)
qet.deleteElement(folioIndex, elementUuid)
qet.undo() // -> false if the stack is empty
qet.redo()
qet.canUndo()
qet.canRedo()These go through the exact same undo commands the GUI itself uses
(AddGraphicsObjectCommand, QPropertyUndoCommand, DeleteQGraphicsItemCommand).
Ctrl+Z in the editor undoes a script's edits exactly as it would the
equivalent manual ones β they are, mechanically, the same commands on the
same stack, not a side channel.
One consequence worth knowing before it surprises you: Qt's undo stack merges
consecutive commands on the same object and property when their label also
matches. setElementPosition() and moveElement() on the same element
produce the same label, so calling them back-to-back collapses into one
undo step β the same way dragging an item repeatedly does, not one step per
call.
locationPath is an element collection path β embed://β¦ for something
already embedded in this project, common://β¦ / custom://β¦ for the shared
collections. Same paths you see in a .qet file's <element type="β¦">
attribute.
deleteElement() removes the symbol and the wires on its terminals, in
one undo step, as the Delete key does. (Before
PR #1270,
merged 2026-10-03, the wires stayed in the project, attached to nothing.) It
refuses to remove an element with a non-deletable terminal (a linked
master/slave, for instance) β same rule the Delete key follows.
The conductor calls name a wire by one of its ends β an element uuid and a terminal index β which cannot tell apart two wires meeting at one terminal. A wire's uuid names it for good:
qet.conductorUuids(folioIndex) // -> ["{β¦}", "{β¦}", β¦], same order as qet.conductors()
qet.conductorEnds(folioIndex, uuid) // -> ["{element uuid} terminal 0", "{element uuid} terminal 2"]
// or [] if the folio has no such wireEach end comes back in the form qet.conductors() prints and the conductor
calls take, so a script can go from a uuid to a call:
var ends = qet.conductorEnds(0, uuid);
var e = ends[0].split(" terminal ");
qet.setConductorProperty(0, e[0], parseInt(e[1], 10), "num", "W12");Wires in a project saved by an older QElectroTech get a uuid worked out from what they connect when the project opens, the same every time, and saved from the next save on (see the project database, section 4).
qet.addConductor() and the conductor calls take a terminal as an element
uuid and a terminal index. The index is the terminal's place in a list
sorted top to bottom, then left to right β not the order the symbol file
lists them in, and undefined between two terminals at the same point.
qet.elementTerminals() shows that numbering, and ends each line with the
terminal's uuid:
qet.elementTerminals(0, elementUuid)
// -> ["0: A1 (1 conductor(s)) {5c1eβ¦}", "1: A2 (0 conductor(s)) {9d04β¦}"]
qet.terminalIndex(0, elementUuid, "{9d04β¦}") // -> 1, or -1 if not foundA terminal's uuid comes from its symbol, so every placed copy of one symbol has the same ones: it names a terminal only together with its element. Hold the uuid, and turn it into the index when calling:
var a2 = qet.terminalIndex(0, coil, "{9d04β¦}");
qet.addConductor(0, coil, a2, lamp, qet.terminalIndex(0, lamp, "{77aaβ¦}"));qet_element_info in the MCP server lists the uuids of a
symbol file's terminals. Every terminal of an opened project has one (see
the project database, section 4).
Most calls name things by position: sheet 2, text 0, table 1. Positions shift when something before them is added or removed, so a script that deletes table 0 and then moves "table 1" moves the wrong one. Hold the uuid instead, and turn it into the current position when you need it:
qet.folioUuid(index) // -> "{β¦}", or ""
qet.folioIndex(uuid) // -> current index, or -1
qet.textIndex(folioIndex, uuid) // a free text, in qet.texts(folioIndex)
qet.shapeIndex(folioIndex, uuid) // a shape, in qet.shapes(folioIndex)
qet.imageIndex(folioIndex, uuid) // a picture, in qet.images(folioIndex)
qet.tableIndex(folioIndex, uuid) // a table, in qet.tables(folioIndex)
qet.elementTextIndex(folioIndex, elementUuid, textUuid)
// a symbol's text field, in qet.elementTexts(folioIndex, elementUuid)Each returns -1 when there is no such item.
-
Symbol text fields take the symbol too, because copies of a symbol keep
their fields' uuids β in
2612_ats_singlephase.qetone field uuid appears on 20 copies. - Older files work too. Most shipped examples save no sheet uuid; a sheet gets one on load, the same on every load, written on the next save.
Since PR #1098 (tables, symbol text fields) and
PR #1114 (sheets), both merged 2026-09-28. The
MCP server's qet_items lists every drawn item with its uuid.
qet.setConductorDefault(folioIndex, property, value) // -> false if refused
qet.conductorDefault(folioIndex, property) // -> "" if unknownThe defaults in PropriΓ©tΓ©s du folio (Sheet properties): what wires drawn
later on the sheet start from, and the sheet-wide option
activer l'option un texte par potentiel, named onetextperfolio
("true" / "false"). The other property names are the ones
setConductorProperty() takes. A folioIndex of -1 means the project's
defaults, which each sheet added later copies.
for (var i = 0; i < qet.folioCount(); i++)
qet.setConductorDefault(i, "onetextperfolio", "true"); // one number per potential
qet.setConductorDefault(-1, "onetextperfolio", "true"); // and on folios added laterChanging onetextperfolio shows or hides the wire numbers at once, so an
export later in the same script draws them right. There is no undo for
these, the same as in the dialogs.
qet.elementGeometry(folioIndex, elementUuid)
// -> {x, y, rotation, left, top, right, bottom}
qet.terminalPosition(folioIndex, elementUuid, terminalIndex)
// -> {x, y, facing} facing is "n", "e", "s" or "w"
qet.conductorPath(folioIndex, conductorUuid)
// -> [{x, y}, {x, y}, β¦]All three return folio coordinates (the same pixels setElementPosition()
takes), and an empty result when there is no such item.
-
elementGeometry():x,yare the symbol's origin, whatsetElementPosition()sets.left/top/right/bottomare the box it covers on the sheet, rotation included. Use it to lay one symbol out next to another, or to check a move landed. -
terminalPosition(): the point where a wire meets the terminal, and which way the wire leaves, the symbol's rotation included. A wire between two terminals that face each other is straight exactly when theirxare equal (terminals facingn/s) or theiryare equal (e/w). So a script can line a symbol up before it draws the wire. -
conductorPath(): a wire's drawn path, by the wire's uuid (fromqet.conductorUuids()). The first point is at the wire's first end (conductorEnds()[0]), the last at its second; the points between are its corners.
Example: put a second coil 40 px under a first one, its top terminal (A1, index 0) in line with the first coil's bottom one (A2, index 1), so the wire between them is straight:
var f = qet.currentFolio();
var path = "common://10_electric/10_allpole/310_relays_contactors_contacts/01_coils/bobine3.elmt";
var k1 = qet.addElement(f, path, 200, 100);
var a = qet.terminalPosition(f, k1, 1); // {x: 200, y: 120, facing: "s"}
var k2 = qet.addElement(f, path, 0, 0);
var b = qet.terminalPosition(f, k2, 0); // facing "n"
qet.moveElement(f, k2, a.x - b.x, a.y + 40 - b.y); // same x, 40 px lower
qet.addConductor(f, k1, 1, k2, 0); // a straight wireCheck which way a terminal faces before relying on it: some symbols have both terminals on one side.
Added in PR #1264
(merged 2026-10-03). The MCP server's place_element and
align_terminal operations do this for an assistant.
addConductor() draws a wire the way the editor does when you join two
terminals: two or three straight segments, which can run straight through
another symbol. These calls change the path of one wire, not the whole
potential:
qet.conductorSegments(folioIndex, elementUuid, terminalIndex)
// -> ["0: (120,160)-(120,170) vertical static", "1: (120,170)-(300,170) horizontal movable", β¦]
qet.moveConductorSegment(folioIndex, elementUuid, terminalIndex, segmentIndex, dx, dy)
qet.routeConductor(folioIndex, elementUuid, terminalIndex)
qet.routeConductorBetween(folioIndex, elementUuidA, terminalIndexA,
elementUuidB, terminalIndexB)
// -> "routed", "no-route", or "" if there is no such wire-
conductorSegments()lists the wire's segments. Astaticsegment is the short one fixed to a terminal; it has no handle in the editor and cannot be moved. -
moveConductorSegment()moves one segment, like dragging its handle: only across its own direction, sodxmoves a vertical segment anddya horizontal one; the other value is ignored. -
routeConductor()redraws the wire so it goes around the symbols in its way, in horizontal and vertical runs on the 10 px grid, inside the sheet's frame. Every symbol's box counts as an obstacle (its texts do not), except a symbol drawn around one of the wire's own ends, such as a frame. Other wires are not obstacles, but the route avoids running along or across them where it can. The new path is one undo step and is saved with the project. -
routeConductorBetween()does the same for the wire joining two given terminals. Use it right afteraddConductor()onto a terminal that already has a wire: the other calls name a wire by one terminal, and refuse a terminal carrying two. -
"no-route"is not a failure: there was no way round, the wire keeps the path it had (still joining the right terminals), and the reason goes to the log.
Route after everything is placed: moving a symbol later stretches the routed path, it does not route it again.
var f = qet.currentFolio();
qet.addConductor(f, k1, 0, k2, 1);
var r = qet.routeConductorBetween(f, k1, 0, k2, 1);
if (r !== "routed") qet.log("left as drawn: " + r);Added in PR #1245, with fixes in #1256 and #1258 (all merged 2026-10-02).
The frame round a sheet is a grid of columns and rows (the PropriΓ©tΓ©s du folio, Sheet properties, dialog sets it):
qet.folioBorder(folioIndex, property) // -> the value as text
qet.setFolioBorder(folioIndex, property, value) // one undo step
qet.folioPresets() // -> ["a0-portrait", "a0-landscape", β¦]| Property | Value |
|---|---|
columns, rows
|
how many, 1 to 99 |
column-width, row-height
|
size in pixels, 1 to 1000 |
display-columns, display-rows
|
"true" / "false": show the headers |
preset |
write only: a sheet of paper, such as "a3-landscape"
|
width, height
|
read only: the frame and title block together, in pixels |
preset takes a0 to a5, letter, legal, tabloid or ledger (the
same 11 Γ 17 in sheet), each followed by -portrait or -landscape. It
chooses whole-number column and row counts and sizes that fill that sheet
without going over it, allowing for the title block, and keeps the cells as
close to their current size as it can. A PDF export of the sheet then comes
out on that paper size.
for (var i = 0; i < qet.folioCount(); i++)
qet.setFolioBorder(i, "preset", "a3-landscape");
qet.log(qet.folioBorder(0, "width") + " x " + qet.folioBorder(0, "height"));Added in PR #1250 (merged 2026-10-02).
qet.houseStyle() // -> the text, or "" if none was set
qet.setHouseStyle(text) // always trueYour own drawing rules, written once in plain words: grid, which way the circuit flows, how wires are routed, where tags go. QElectroTech keeps the text in its settings, the same for every project, and every AI assistant that connects reads it (see Connecting an AI assistant). QElectroTech itself does nothing else with it.
Added in PR #1315 (merged 2026-10-05).
qet.selectElement(elementUuid) // scene state; works headless, no view needed
qet.deselectAll(folioIndex)
qet.zoomFit() // -> false headless (no view to act on)
qet.zoomToContent()
qet.zoomReset()
qet.showMessage("Done.") // a modal info box; safe headless (auto-dismissed)Deliberately not here: triggering an arbitrary menu action by name. A
script that could invoke any QAction could just as easily open a modal
dialog with nobody there to dismiss it β a real, previously-hit hang class in
this codebase (see the discussion on
#882).
Every method above is either non-blocking by construction, or β for
showMessage β safe under QET's existing non-interactive mode, which is
already on for the whole process before any headless script runs.
var f = qet.currentFolio() // index of the sheet on screen; 0 headless; -1 if the project has none
qet.apiSignatures() // every call this build offers, with its argumentscurrentFolio() lets a script act where the user is looking, which a
script button needs. apiSignatures() comes from the
running build itself, so a list made from it (as the MCP server's
qet_script_api does) cannot drift from what the build really has.
qet.log("anything you want in stderr")A script has no console of its own; this is how you see output, including
under --run.
The sections above explain the calls most scripts need. This is the whole
list in current development builds, by what they act on. For the arguments of
any of them, run qet.log(qet.apiSignatures().join("\n")): the list comes
from your own build, so it is always right for it.
| Acts on | Calls |
|---|---|
| the project and its sheets |
projectTitle, setProjectTitle, filePath, folioCount, currentFolio, folioTitle, setFolioTitle, folioUuid, folioIndex, addFolio, insertFolio, removeFolio, folioProperty, setFolioProperty, folioBorder, setFolioBorder, folioPresets, titleBlockTemplates, embedTitleBlockTemplate
|
| export and save |
exportPdf, exportPng, exportSvg, exportDxf, exportCables, exportWires, exportBom, exportWiring, exportNets, exportLinks, exportInfo, setTitleBlock, save
|
| symbols |
elementCount, elementUuids, elementName, addElement, setElementPosition, moveElement, rotateElement, deleteElement, duplicateElements, elementGeometry, elementInfo, setElementInfo, elementLabel, setElementLabel
|
| terminals |
elementTerminals, terminalIndex, terminalPosition
|
| a symbol's text fields |
elementTexts, elementTextIndex, addElementText, elementTextProperty, setElementTextProperty, elementTextGeometry, deleteElementText
|
| wires |
conductorCount, conductors, conductorUuids, conductorEnds, addConductor, deleteConductor, conductorProperty, setConductorProperty, conductorDefault, setConductorDefault
|
| a wire's path |
conductorPath, conductorSegments, moveConductorSegment, routeConductor, routeConductorBetween
|
| cross-references and PLC |
elementLinkType, linkedElements, linkElements, unlinkElement, elementLinkGroupIndex, plcIOs, addPlcIO, setPlcIO, removePlcIO
|
| free texts |
texts, textIndex, addText, textContent, setTextContent, setTextColor, setTextRotation, deleteText
|
| shapes |
shapes, shapeIndex, addShape, shapeProperty, setShapeProperty, deleteShape, addPolygon, shapePolygon, setShapePolygon, addPath, shapePathNodes, setShapePathNodes, setShapeClosed
|
| pictures |
images, imageIndex, addImage, addPdfPage, setImageScale, setImageRotation, cropImage, imageCrop, deleteImage
|
| tables on a sheet |
tables(folioIndex), tableIndex, addTable, setTablePosition, deleteTable
|
| terminal strips |
terminalStrips, addTerminalStrip, removeTerminalStrip, addTerminalToStrip, stripRealTerminals, groupTerminals, bridgeTerminals, sortTerminalStrip
|
| auto-numbering |
autoNums, addAutoNum, removeAutoNum, renameAutoNum, useConductorAutoNum, useElementAutoNum, numberElement, renumberElementAutoNum, freeElementNumbers, assignElementNumber, assignElementAutoNum
|
| the project database |
tables(), query, queryError (read-only SELECT; see the project database) |
| checks, search |
checkContinuity, searchAndReplace
|
| undo |
undo, redo, canUndo, canRedo
|
| the window |
selectElement, deselectAll, selectedElements, zoomFit, zoomToContent, zoomReset, showMessage
|
| this installation |
houseStyle, setHouseStyle, log, apiSignatures
|
tables has two forms: with no argument it lists the database's tables and
views, with a sheet index the tables drawn on that sheet.
// bump_and_export.js
qet.setTitleBlock("", ["revision=" + qet.projectTitle(), "date=today"]);
qet.save("");
qet.exportPdf(qet.filePath().replace(".qet", ".pdf"));
qet.exportBom(qet.filePath().replace(".qet", "_bom.csv"));
qet.log("Exported revision for " + qet.projectTitle());qelectrotech --run bump_and_export.js panel.qet#!/bin/bash
set -e
for f in projects/*.qet; do
qelectrotech --run ci_check.js "$f"
done// ci_check.js
if (qet.folioCount() === 0) {
qet.log("ERROR: no folios in " + qet.filePath());
throw new Error("empty project");
}
var ok = qet.exportPdf("/tmp/check.pdf");
if (!ok) throw new Error("PDF export failed");A thrown error exits 1 and CI fails the step β no separate exit-code
plumbing needed.
var placements = [
["embed://β¦/relay.elmt", 100, 100],
["embed://β¦/contactor.elmt", 200, 100],
["embed://β¦/breaker.elmt", 300, 100],
];
for (var i = 0; i < placements.length; i++) {
var p = placements[i];
var uuid = qet.addElement(0, p[0], p[1], p[2]);
if (!uuid) qet.log("failed to place: " + p[0]);
}
qet.save("");| Situation | Exit code |
|---|---|
| Script ran to completion | 0 |
| Script threw an uncaught exception | 1 |
| Project failed to open | 1 |
Missing script.js or project.qet argument |
2 |
| Script or project file not found | 2 |
Scripting is off (no setting, no QET_ENABLE_SCRIPTING=1) |
3 |
An uncaught exception is reported as Script error: <path>:<line>: <message>
on stderr.
Same three lines as the class-level scope in the source, kept here so a change of mind shows up in one obvious place:
- No arbitrary GUI actions. See the "Navigate and message" section above for exactly why.
-
Export methods don't see unsaved edits without an explicit
save()first β see the callout above. -
This is not a plugin system. No script runs automatically, on open or
otherwise; every run is explicit, either a
--runinvocation or a menu click. There is still no loadable-module directory and no way for a project file to carry or trigger a script β see Automating QElectroTech for the file-format boundary this respects.
- CLI Reference β the export flags this feature wraps
- Automating QElectroTech β the file-format and headless export ground this builds on
-
MCP server β an AI assistant driving this engine through
qet_edit/qet_query - Script buttons β store a script and run it from a button
- Live mode β an assistant running scripts in the open project
- Macro recorder β record a task by hand for an assistant to script
- PR #891 β implementation, with the exact tests run against it
- Issue #162 β the original request and design discussion
Getting Started
π Languages β English Β· FranΓ§ais Β· Deutsch
Windows without admin rights β the portable archive, no installer
Guides
Conductors β wire properties, what feeds which export, cables, and hops where wires cross
Wires per terminal β limit the wires on a terminal, chain wiring instead of stars
Printing and exporting β paper, PDF, images, and what each path does differently
Linking elements β master, slave, terminal
PLC modules β I/O tables and linking a wire to a specific point
Using the element editor β drawing tools, saving, checks
Grid size and element size β why symbols aren't all the same scale, and scaling one without leaving the grid
Preferences reference β what each settings page does
Saving and loading settings β your whole setup in one file, to copy or keep
Keyboard-only control β mouseless QET, and what still needs a mouse
Mouse modifiers β what Shift, Ctrl and Alt change while you drag
3D mouse β SpaceMouse pan, zoom and buttons
Aligning items β snap symbols back to the grid, or line them up
Pictures on a sheet β labels, crop, transparency, what they cost in the file
Arcs and curved wires β the Arc tool, pulling an arc in or out, rounding a corner with a fillet, dashed arcs for lighting layouts
Grouping items β select, move and copy several items as one
Finding your place on a sheet β go to a cell like B13 or 4-B7, keep the headers in sight, show the cell limits, zoom and pan
Showing and hiding kinds of items β hide texts, wire numbers, shapes, pictures, tables or cross-references on every sheet
Drawing faster β place without dragging, the S shortcut bar, command search, gestures
Customising QElectroTech β keys, toolbar size and contents, the gesture ring (partly pending)
Managing collections β folders, writability, building your own shortlist
Templates β reusable multi-element blocks, placed by double-click or drag
Search & Replace β bulk property changes
Building a nomenclature query β the BOM/summary table builder
Linking wires across pages β sheet reports
Variables & formulas β %f, %{label}, sequences
Auto-numbering β schemes, sequences, freezing
Terminal strips β strips, levels, bridges
Title block templates β the .titleblock format
Importing EPLAN parts (.edz) β EPLAN Data Portal
DXF import & export β two unrelated features, one format; command-line export and layers
The project database β the in-memory SQLite cache
Development
Automating QET β CLI, XML formats, external tools
CLI Reference β command line usage
JavaScript Scripting β --run, geometry editing, undo
MCP server β let an AI assistant read, verify and edit projects
Connecting an AI assistant β setup for Claude, Copilot, Gemini, Codex, Cursor, LM Studio
Script buttons β stored scripts with an icon, by hand or by an assistant
Live mode β an assistant working in the open project while you watch
Macro recorder β record a task by hand, for an assistant to script
Vision β proposal, under discussion
