qet-mcp: qet_edit names a terminal 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. It names a
terminal of the op's own element -- for add_conductor, of that end's
element, "$name" references included -- and is turned at run time into
the index the call takes by qet.terminalIndex(); if the element has no
such terminal the op fails with a note. Unlike the index, a sort by
position, it is defined between two terminals at the same point. The
lookup is required only when a uuid is used, so index-only edits still
run on older builds.

README: the terminal uuids; wires of older projects now get a lasting
uuid (#1107) instead of staying unnamed.

Tests: script generation for every terminal-taking op, "$name" and folio
uuid resolution, the index still passed through; through the binary,
bobine_ka_a_remanence (file order A2, A1; index order A1, A2) wired A2 to
A1 by uuid lands on exactly those ends, and an unknown uuid stops the run
with its note. 255/255 against a build with qet.terminalIndex(); a build
without it is reported as missing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-29 10:54:03 +13:00
parent 3112727ee7
commit f58bf7502e
3 changed files with 121 additions and 10 deletions
+13 -3
View File
@@ -305,9 +305,19 @@ Python, plus the hang guard on `addConductor` and the database refresh in
`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: QElectroTech makes a new one on every load and does not save
it, so `qet_conductors` reports `uuid` as empty and those conductors are
named by `element` + `terminal` until wire uuids last (#1103).
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`.
- **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.
- **`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*
+38 -7
View File
@@ -1292,14 +1292,14 @@ OPS = {
"set_info": ("setElementInfo", [("folio", "folio"), ("element", "elmt"),
("key", "str"), ("value", "str")]),
"add_conductor": ("addConductor", [("folio", "folio"),
("from", "elmt"), ("from_terminal", "num"),
("to", "elmt"), ("to_terminal", "num")]),
("from", "elmt"), ("from_terminal", "term"),
("to", "elmt"), ("to_terminal", "term")]),
"delete_element": ("deleteElement", [("folio", "folio"), ("element", "elmt")]),
"set_conductor": ("setConductorProperty", [("folio", "folio"), ("element", "elmt"),
("terminal", "num"), ("property", "str"),
("terminal", "term"), ("property", "str"),
("value", "str")]),
"move_conductor_segment": ("moveConductorSegment", [("folio", "folio"), ("element", "elmt"),
("terminal", "num"), ("segment", "num"),
("terminal", "term"), ("segment", "num"),
("dx", "num"), ("dy", "num")]),
"link_elements": ("linkElements", [("folio", "folio"), ("element", "elmt"),
("to_folio", "folio"), ("to", "elmt")]),
@@ -1320,7 +1320,7 @@ OPS = {
# uuid, which does not shift when another one is added. So
# it can be named as "$id". Indexes shift when one is added or deleted.
"delete_conductor": ("deleteConductor", [("folio", "folio"), ("element", "elmt"),
("terminal", "num")]),
("terminal", "term")]),
"remove_folio": ("removeFolio", [("folio", "folio")]),
"set_folio": ("setFolioProperty", [("folio", "folio"), ("property", "str"),
("value", "str")]),
@@ -1469,6 +1469,7 @@ def _build_script(operations: list, output: str) -> str:
uuid_methods: set[str] = set()
folio_js = "0"
element_js = None # the op's element, for lookups scoped to it
owner_js = None # the element a terminal argument belongs to
lines = [
"// generated by qet-mcp; do not edit",
"var R = {};", # $name -> value from an earlier op
@@ -1509,6 +1510,16 @@ def _build_script(operations: list, output: str) -> str:
"{kind: 'op_note', index: index, note: why}));",
" return null;",
"}",
# A terminal named by uuid is turned into the index the calls take,
# on its own element; -1, with the reason logged, if that element
# has no terminal with it.
"function qetMcpTerminal(index, folio, element, uuid) {",
" var t = qet.terminalIndex(folio, element, uuid);",
" if (t < 0) qet.log(" + _js(_MARKER) + " + JSON.stringify({kind: 'op_note', "
"index: index, note: 'no terminal ' + uuid + ' on element ' + element + "
"' (or two of its terminals carry it)'}));",
" return t;",
"}",
"if (missing.length === 0) {",
]
@@ -1561,6 +1572,20 @@ def _build_script(operations: list, output: str) -> str:
raise ValueError(f"operation {op_index}: {key!r} must be a table index "
f"or its uuid, got {value!r}")
return _js(value)
if kind == "term":
# A terminal's uuid comes from its symbol's definition, so it
# names a terminal only on its element: the op's element, or for
# add_conductor the end's own. Turned into the index the call
# takes at run time; unlike the index, it is defined between two
# terminals at the same point.
if isinstance(value, str) and _UUID_RE.fullmatch(value):
uuid_methods.add("terminalIndex")
return (f"qetMcpTerminal({op_index}, {folio_js}, {owner_js}, "
f"{_js(value)})")
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"operation {op_index}: {key!r} must be a terminal index "
f"or its uuid, got {value!r}")
return _js(value)
if kind == "element_text":
# A field's uuid is unique only within its element (copies keep
# them), so the lookup takes the op's element too.
@@ -1700,6 +1725,7 @@ def _build_script(operations: list, output: str) -> str:
raise ValueError(f"operation {i} ({name}) is missing {key!r}")
folio_js = args[0] if args else "0"
element_js = args[1] if len(args) > 1 else None
owner_js = args[-1] if args else None # a terminal's element precedes it
args.append(ref_or(op[key], kind, i, key))
if conductor_js is not None:
args[1], args[2] = f"e{i}.element", f"e{i}.terminal"
@@ -2635,8 +2661,13 @@ TOOLS = [
"qet_project_info call 1 is \"folio\": 0 here. A folio can "
"be given as its uuid instead (qet_project_info lists them), "
"which still names the same folio after an earlier op adds or "
"removes one. Terminals are numbered by their "
"index in the element definition; qet_element_info lists them. "
"removes one. A terminal (\"terminal\", \"from_terminal\", "
"\"to_terminal\") is given by its index -- qet_element_info "
"lists terminals in index order, top to bottom then left to "
"right -- or by its uuid, which qet_element_info also lists and "
"which, unlike the index, tells apart two terminals at the same "
"point; a terminal uuid names a terminal of the op's own "
"element (for add_conductor, of that end's element). "
"set_conductor addresses a conductor as the one on a given "
"terminal and applies the change to its whole electrical "
"potential, so name a terminal carrying exactly one conductor, "
+70
View File
@@ -242,6 +242,43 @@ class EditValidation(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "saved before conductors carried a uuid"):
self.build([{"op": "delete_conductor", "folio": 0, "conductor": ""}])
def test_terminal_by_uuid(self):
"""A terminal named by uuid is turned into its index at run time, on
the element it belongs to: the op's element, or each add_conductor
end's own. The lookup is required only then; a plain index is
passed through as before."""
E, F = "{11111111-2222-4333-8444-555555555555}", "{21111111-2222-4333-8444-555555555555}"
T, V = "{31111111-2222-4333-8444-555555555555}", "{41111111-2222-4333-8444-555555555555}"
s = self.build([{"op": "add_conductor", "folio": 1, "from": E, "from_terminal": T,
"to": F, "to_terminal": V}])
self.assertIn(f'qet.addConductor(1, "{E}", qetMcpTerminal(0, 1, "{E}", "{T}"), '
f'"{F}", qetMcpTerminal(0, 1, "{F}", "{V}"))', s)
self.assertIn('"terminalIndex"', s)
s = self.build([{"op": "set_conductor", "folio": 0, "element": E, "terminal": T,
"property": "num", "value": "W1"}])
self.assertIn(f'qet.setConductorProperty(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"), '
'"num", "W1")', s)
s = self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": T}])
self.assertIn(f'qet.deleteConductor(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"))', s)
s = self.build([{"op": "move_conductor_segment", "folio": 0, "element": E,
"terminal": T, "segment": 1, "dx": 5, "dy": 0}])
self.assertIn(f'qet.moveConductorSegment(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"), 1, 5, 0)', s)
# a $name element and a folio uuid reach the lookup resolved
s = self.build([{"op": "add_folio", "id": "f"},
{"op": "add_element", "id": "a", "folio": "$f", "path": "x.elmt", "x": 0, "y": 0},
{"op": "delete_conductor", "folio": "$f", "element": "$a", "terminal": T}])
self.assertIn(f'qet.deleteConductor(R["f"], R["a"], qetMcpTerminal(2, R["f"], R["a"], "{T}"))', s)
s = self.build([{"op": "delete_conductor", "folio": V, "element": E, "terminal": T}])
self.assertIn(f'qetMcpTerminal(0, qet.folioIndex("{V}"), "{E}", "{T}")', s)
# an index is unchanged and needs no lookup
s = self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": 2}])
self.assertIn(f'qet.deleteConductor(0, "{E}", 2)', s)
self.assertNotIn('"terminalIndex"', s)
with self.assertRaisesRegex(ValueError, "terminal index or its uuid"):
self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": "A1"}])
with self.assertRaisesRegex(ValueError, "terminal index or its uuid"):
self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": True}])
def test_every_op_generates_a_script(self):
# one minimal valid instance of every op
f = {"op": "add_folio", "id": "f"}
@@ -2235,6 +2272,39 @@ class Integration(unittest.TestCase):
nums = [c["num"] for c in m.tool_conductors(r["output"])["conductors"]]
self.assertEqual(nums, ["W1"])
def test_add_conductor_by_terminal_uuid(self):
"""The coil's file lists A2 before A1, and its index order puts A1
first: wiring by uuid reaches the terminals named, whatever their
index, and an unknown uuid stops the run with a note."""
info = m.tool_element_info(str(Path(ELEMENTS) / COIL.removeprefix("common://")))
by_name = {t["name"]: t for t in info["terminals"]}
a1, a2 = by_name["A1"], by_name["A2"]
self.assertTrue(a1["uuid"] and a2["uuid"])
self.assertEqual((a1["index"], a2["index"]), (0, 1))
base = self.sb.new()
r = self.ok(self.sb.edit(base, [
{"op": "add_element", "id": "a", "folio": 0, "path": COIL, "x": 100, "y": 100},
{"op": "add_element", "id": "b", "folio": 0, "path": COIL, "x": 300, "y": 100},
{"op": "add_conductor", "folio": 0, "from": "$a", "from_terminal": a2["uuid"],
"to": "$b", "to_terminal": a1["uuid"]}]))
self.assertTrue(all(o["succeeded"] for o in r["operations"]))
wires = [c for c in ET.parse(r["output"]).getroot().iter("conductor")
if c.get("terminal1") and c.get("element1")]
self.assertEqual(len(wires), 1)
ends = {(wires[0].get("element1"), wires[0].get("terminal1")),
(wires[0].get("element2"), wires[0].get("terminal2"))}
a_uuid, b_uuid = (o["result"] for o in r["operations"][:2])
self.assertEqual(ends, {(a_uuid, a2["uuid"]), (b_uuid, a1["uuid"])})
r = self.sb.edit(base, [
{"op": "add_element", "id": "a", "folio": 0, "path": COIL, "x": 100, "y": 100},
{"op": "add_conductor", "folio": 0, "from": "$a",
"from_terminal": "{00000000-0000-4000-8000-000000000001}",
"to": "$a", "to_terminal": a1["uuid"]}])
self.assertFalse(r["ok"])
self.assertTrue(r["stopped_early"])
self.assertIn("no terminal", json.dumps(r["operations"][1]))
def test_noop_edit_has_no_conductor_churn(self):
"""Re-saving renumbers the file's terminal ids; the diff must not
report the untouched conductors as removed and re-added."""