diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 4ff88f7b7..36bd4969e 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -410,6 +410,29 @@ drawing. The check is read-only. On a QElectroTech build without 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 diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 009c0aa19..c5ea6af52 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1524,6 +1524,41 @@ OPS = { "set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "table"), ("x", "num"), ("y", "num")]), "delete_table": ("deleteTable", [("folio", "folio"), ("table", "table")]), + # Lining symbols up. These run several calls each, in helpers the + # generated script defines (qetMcp*), not one scripting call. + "align_elements": ("qetMcpAlign", [("folio", "folio"), ("elements", "elmts"), + ("edge", "str"), ("to", "elmt")]), + "distribute_elements": ("qetMcpDistribute", [("folio", "folio"), ("elements", "elmts"), + ("axis", "str"), ("pitch", "num")]), + # Place a symbol so one of its terminals is exactly in line with + # another symbol's: the wire between the two is then straight. Returns + # the new element's uuid, as add_element does. + "place_element": ("qetMcpPlace", [("folio", "folio"), ("path", "str"), + ("terminal", "anyterm"), ("next_to", "elmt"), + ("next_to_terminal", "anyterm"), + ("side", "str"), ("gap", "num"), + ("angle", "num")]), + "align_terminal": ("qetMcpAlignTerminal", [("folio", "folio"), ("element", "elmt"), + ("terminal", "anyterm"), ("to", "elmt"), + ("to_terminal", "anyterm")]), +} + +# Arguments those ops may leave out. +OP_DEFAULTS = { + "align_elements": {"to": ""}, + "distribute_elements": {"pitch": 0}, + "place_element": {"side": "", "gap": 40, "angle": 0}, +} +ALIGN_EDGES = ["left", "center", "right", "top", "middle", "bottom"] +DISTRIBUTE_AXES = ["horizontal", "vertical"] +PLACE_SIDES = ["", "below", "above", "right", "left"] +# What the helpers call, beyond what every edit needs. +_HELPER_NEEDS = { + "qetMcpAlign": {"elementGeometry", "moveElement"}, + "qetMcpDistribute": {"elementGeometry", "moveElement"}, + "qetMcpPlace": {"elementGeometry", "addElement", "deleteElement", "moveElement", + "rotateElement", "terminalPosition", "terminalIndex"}, + "qetMcpAlignTerminal": {"moveElement", "terminalPosition", "terminalIndex"}, } SHAPES = ["line", "rectangle", "ellipse", "polygon"] @@ -1579,7 +1614,8 @@ CONDUCTOR_DEFAULT_PROPERTIES = ["onetextperfolio"] + CONDUCTOR_PROPERTIES # verbs. Probed in the script rather than assumed, because the failure mode # otherwise is a TypeError on line N of a generated file the caller never # sees, reported as "the edit failed". -_REQUIRED_METHODS = sorted(({m for m, _ in OPS.values() if m} - _ROUTE_METHODS) | +_REQUIRED_METHODS = sorted(({m for m, _ in OPS.values() + if m and not m.startswith("qetMcp")} - _ROUTE_METHODS) | {"save", "folioCount", "conductorCount", "elementCount"}) _MARKER = "QETEDIT " @@ -1661,6 +1697,97 @@ def _build_script(operations: list, output: str) -> str: "' (or two of its terminals carry it)'}));", " return t;", "}", + # The layout ops' helpers. qetMcpOp is the index of the op running, + # for the notes they log. + "var qetMcpOp = -1;", + "function qetMcpNote(note) {", + f" qet.log({_js(_MARKER)} + JSON.stringify(" + "{kind: 'op_note', index: qetMcpOp, note: note}));", + "}", + "function qetMcpTerm(folio, element, t) {", + " return typeof t === 'string' ? qet.terminalIndex(folio, element, t) : t;", + "}", + "function qetMcpAlign(folio, els, edge, to) {", + " var ref = qet.elementGeometry(folio, to || els[0]);", + " if (ref.left === undefined) { qetMcpNote('no element ' + (to || els[0])); return false; }", + " var across = edge === 'left' || edge === 'center' || edge === 'right';", + " function at(g) {", + " if (edge === 'center') return (g.left + g.right) / 2;", + " if (edge === 'middle') return (g.top + g.bottom) / 2;", + " return g[edge];", + " }", + " var moved = 0;", + " for (var i = 0; i < els.length; i++) {", + " if (els[i] === to) continue;", + " var g = qet.elementGeometry(folio, els[i]);", + " if (g.left === undefined) { qetMcpNote('no element ' + els[i]); return false; }", + " var d = at(ref) - at(g);", + " if (Math.abs(d) < 1e-9) continue;", + " if (!qet.moveElement(folio, els[i], across ? d : 0, across ? 0 : d)) return false;", + " moved++;", + " }", + " return moved;", + "}", + "function qetMcpDistribute(folio, els, axis, pitch) {", + " var k = axis === 'horizontal' ? 'x' : 'y', gs = [];", + " for (var i = 0; i < els.length; i++) {", + " var g = qet.elementGeometry(folio, els[i]);", + " if (g.left === undefined) { qetMcpNote('no element ' + els[i]); return false; }", + " gs.push({e: els[i], v: g[k]});", + " }", + " gs.sort(function (a, b) { return a.v - b.v; });", + " var n = gs.length, first = gs[0].v;", + " var step = pitch > 0 ? pitch : (gs[n - 1].v - first) / (n - 1);", + " var moved = 0;", + " for (var j = 1; j < n; j++) {", + " var t = first + j * step;", + # an even split of a gap that is not a whole number of grid steps + # rounds each place to the grid, so symbols on it stay on it + " if (pitch <= 0 && j < n - 1) t = first + Math.round((t - first) / 10) * 10;", + " var d = t - gs[j].v;", + " if (Math.abs(d) < 1e-9) continue;", + " if (!qet.moveElement(folio, gs[j].e, k === 'x' ? d : 0, k === 'y' ? d : 0)) return false;", + " moved++;", + " }", + " return moved;", + "}", + "var qetMcpStep = {below: [0, 1], above: [0, -1], right: [1, 0], left: [-1, 0]};", + "var qetMcpFacingSide = {s: 'below', n: 'above', e: 'right', w: 'left'};", + "var qetMcpBack = {below: 'n', above: 's', right: 'w', left: 'e'};", + "function qetMcpPlace(folio, path, term, near, nearTerm, side, gap, angle) {", + " var g = qet.elementGeometry(folio, near);", + " if (g.left === undefined) { qetMcpNote('no element ' + near); return ''; }", + " var to = qet.terminalPosition(folio, near, qetMcpTerm(folio, near, nearTerm));", + " if (to.x === undefined) { qetMcpNote('next_to_terminal ' + nearTerm + ' is not a terminal of ' + near); return ''; }", + " var el = qet.addElement(folio, path, g.x, g.y);", + " if (!el) return '';", + # turned first, so the terminal is measured where it ends up + " if (angle && !qet.rotateElement(folio, el, angle)) { qet.deleteElement(folio, el); return ''; }", + " var mine = qet.terminalPosition(folio, el, qetMcpTerm(folio, el, term));", + " if (mine.x === undefined) {", + " qet.deleteElement(folio, el);", + " qetMcpNote('terminal ' + term + ' is not a terminal of ' + path);", + " return '';", + " }", + " side = side || qetMcpFacingSide[to.facing];", + " var s = qetMcpStep[side];", + " if (!qet.moveElement(folio, el, to.x + s[0] * gap - mine.x, to.y + s[1] * gap - mine.y)) return '';", + " if (mine.facing !== qetMcpBack[side])", + " qetMcpNote('terminal ' + term + ' of the new symbol faces ' + mine.facing + ', not '", + " + qetMcpBack[side] + ': the wire to it will bend. Rotate the symbol (rotate_element) '", + " + 'or name another terminal');", + " return el;", + "}", + "function qetMcpAlignTerminal(folio, el, term, to, toTerm) {", + " var a = qet.terminalPosition(folio, to, qetMcpTerm(folio, to, toTerm));", + " var b = qet.terminalPosition(folio, el, qetMcpTerm(folio, el, term));", + " if (a.x === undefined || b.x === undefined) {", + " qetMcpNote('terminal not found: ' + (a.x === undefined ? to + ' ' + toTerm : el + ' ' + term));", + " return false;", + " }", + " var along = a.facing === 'n' || a.facing === 's';", + " return qet.moveElement(folio, el, along ? a.x - b.x : 0, along ? 0 : a.y - b.y);", + "}", "if (missing.length === 0) {", ] @@ -1732,6 +1859,16 @@ def _build_script(operations: list, output: str) -> str: 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 == "anyterm": + # A terminal of an element the helper resolves itself (for + # place_element, one that does not exist yet): its index, or its + # uuid, which the helper turns into the index. + if isinstance(value, str) and _UUID_RE.fullmatch(value): + return _js(value) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + 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. @@ -1869,6 +2006,36 @@ def _build_script(operations: list, output: str) -> str: # The conductor ops take "conductor": "{uuid}" in place of element + # terminal: a uuid names one conductor for good, where a terminal # can carry several. + if name in OP_DEFAULTS: + op = {**OP_DEFAULTS[name], **op} + if name == "align_elements": + if op.get("edge") not in ALIGN_EDGES: + raise ValueError(f"operation {i}: unknown edge {op.get('edge')!r}; " + f"expected one of {', '.join(ALIGN_EDGES)}") + if not isinstance(op.get("elements"), list) or len(op["elements"]) < 2: + raise ValueError(f"operation {i}: align_elements needs at least 2 elements") + if name == "distribute_elements": + if op.get("axis") not in DISTRIBUTE_AXES: + raise ValueError(f"operation {i}: unknown axis {op.get('axis')!r}; " + f"expected one of {', '.join(DISTRIBUTE_AXES)}") + pitch = op.get("pitch") + if isinstance(pitch, (int, float)) and not isinstance(pitch, bool) and pitch < 0: + raise ValueError(f"operation {i}: pitch must be >= 0 (0: spread evenly)") + least = 2 if isinstance(pitch, (int, float)) and pitch > 0 else 3 + if not isinstance(op.get("elements"), list) or len(op["elements"]) < least: + raise ValueError(f"operation {i}: distribute_elements needs at least " + f"{least} elements" + ("" if least == 2 else + " (or 2 with a \"pitch\")")) + if name == "place_element": + if op.get("side") not in PLACE_SIDES: + raise ValueError(f"operation {i}: unknown side {op.get('side')!r}; expected " + f"one of {', '.join(s for s in PLACE_SIDES if s)}, or none " + "for the way next_to_terminal faces") + gap = op.get("gap") + if isinstance(gap, (int, float)) and not isinstance(gap, bool) and gap <= 0: + raise ValueError(f"operation {i}: gap must be > 0") + if method and method.startswith("qetMcp"): + uuid_methods.update(_HELPER_NEEDS[method]) conductor_js = None if name in CONDUCTOR_UUID_OPS and "conductor" in op: if "element" in op or "terminal" in op: @@ -1905,7 +2072,10 @@ def _build_script(operations: list, output: str) -> str: raise ValueError(f"operation {i}: \"id\" {ident!r} is already used") call = "qet.addFolio()" if method is None else f"qet.{method}({', '.join(args)})" + if method and method.startswith("qetMcp"): + call = f"{method}({', '.join(args)})" lines.append(" if (!stop) {") + lines.append(f" qetMcpOp = {i};") if conductor_js is not None: lines.append(f" var e{i} = qetMcpConductorEnd({i}, {args[0]}, {conductor_js});") call = f"(e{i} ? {call} : false)" @@ -4213,6 +4383,22 @@ TOOLS = [ "description": "Operations applied in order. Each is an object with \"op\" " "and that op's arguments. Ops: " + ", ".join(sorted(OPS)) + ". " + "Lining symbols up, so wires come out straight: a wire is " + "straight only when its two terminals are exactly in line. " + "place_element (folio, path, terminal, next_to, " + "next_to_terminal, optional side below|above|right|left -- " + "default the way next_to_terminal faces -- gap, 40 px " + "terminal to terminal, and angle, a rotation applied first, as " + "for a ladder rung) adds a symbol with its terminal in line " + "with next_to's, and notes when that terminal faces the wrong " + "way; align_terminal (folio, element, terminal, to, " + "to_terminal) moves a placed symbol across so the two are in " + "line; align_elements (folio, elements, edge left|center|right|" + "top|middle|bottom, optional to) lines up their boxes, as Edit > " + "Align does; distribute_elements (folio, elements, axis " + "horizontal|vertical, optional pitch) spaces their origins " + "evenly, or pitch apart. place_element and align_terminal need " + "a build with qet.terminalPosition(). " "Give an op an \"id\" to name what it produced, then refer to " "it later as \"$id\" -- that is how an element placed by " "add_element gets wired by add_conductor, and how a folio made " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index a733eb1c7..0ece9ec19 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -474,6 +474,16 @@ class EditValidation(unittest.TestCase): "set_plc_io": el + [{"op": "set_plc_io", "folio": "$f", "element": "$e", "index": 0, "property": "address", "value": "1.1"}], "remove_plc_io": el + [{"op": "remove_plc_io", "folio": "$f", "element": "$e", "index": 0}], + "align_elements": two + [{"op": "align_elements", "folio": "$f", + "elements": ["$e", "$e2"], "edge": "middle"}], + "distribute_elements": two + [{"op": "distribute_elements", "folio": "$f", + "elements": ["$e", "$e2"], "axis": "vertical", + "pitch": 80}], + "place_element": el + [{"op": "place_element", "folio": "$f", "path": "common://x.elmt", + "terminal": 0, "next_to": "$e", "next_to_terminal": 1, + "side": "below", "gap": 60}], + "align_terminal": two + [{"op": "align_terminal", "folio": "$f", "element": "$e2", + "terminal": 0, "to": "$e", "to_terminal": 1}], }) self.assertEqual(set(samples), set(m.OPS), "an op has no sample here: add one so it is exercised") @@ -1528,6 +1538,65 @@ class LayoutCheckTool(unittest.TestCase): self.assertEqual(r["hint"], "boom") +class LayoutOpsValidation(unittest.TestCase): + """align_elements, distribute_elements, place_element, align_terminal: + their arguments are checked before QElectroTech starts, and the script + asks for terminalPosition() only when an op needs it.""" + + A, B, C = ("{00000000-0000-0000-0000-00000000000a}", "{00000000-0000-0000-0000-00000000000b}", + "{00000000-0000-0000-0000-00000000000c}") + + def bad(self, op, needle): + with self.assertRaises(ValueError) as cm: + m._build_script([op], "/tmp/x.qet") + self.assertIn(needle, str(cm.exception)) + + def test_align_arguments(self): + self.bad({"op": "align_elements", "folio": 0, "elements": [self.A, self.B], + "edge": "diagonal"}, "unknown edge") + self.bad({"op": "align_elements", "folio": 0, "elements": [self.A], "edge": "left"}, + "at least 2") + + def test_distribute_arguments(self): + self.bad({"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B, self.C], + "axis": "diagonal"}, "unknown axis") + self.bad({"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B], + "axis": "horizontal"}, "at least 3") + self.bad({"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B, self.C], + "axis": "vertical", "pitch": -10}, "pitch must be >= 0") + m._build_script([{"op": "distribute_elements", "folio": 0, "elements": [self.A, self.B], + "axis": "vertical", "pitch": 80}], "/tmp/x.qet") + + def test_place_arguments(self): + base = {"op": "place_element", "folio": 0, "path": "common://x.elmt", "terminal": 0, + "next_to": self.A, "next_to_terminal": 1} + self.bad({**base, "side": "up"}, "unknown side") + self.bad({**base, "gap": 0}, "gap must be > 0") + self.bad({**base, "terminal": True}, "terminal index or its uuid") + self.bad({**base, "terminal": -1}, "terminal index or its uuid") + self.bad({**base, "terminal": "A1"}, "terminal index or its uuid") + s = m._build_script([{**base, "terminal": "{11111111-2222-3333-4444-555555555555}"}], + "/tmp/x.qet") + self.assertIn('qetMcpPlace(0, "common://x.elmt", "{11111111-2222-3333-4444-555555555555}", ' + f'"{self.A}", 1, "", 40, 0)', s) + + def test_helpers_are_called_bare_and_needs_follow_use(self): + place = m._build_script([{"op": "align_terminal", "folio": 0, "element": self.A, + "terminal": 0, "to": self.B, "to_terminal": 1}], "/tmp/x.qet") + self.assertIn("qetMcpAlignTerminal(0, ", place) + self.assertNotIn("qet.qetMcp", place) + self.assertIn('"terminalPosition"', place) + plain = m._build_script([{"op": "move_element", "folio": 0, "element": self.A, + "dx": 1, "dy": 0}], "/tmp/x.qet") + self.assertNotIn("terminalPosition\"", plain.split("var need = ")[1].split(";")[0]) + self.assertNotIn("qetMcpAlign\"", plain) + + def test_defaults_fill_in(self): + s = m._build_script([{"op": "align_elements", "folio": 0, "elements": [self.A, self.B], + "edge": "center"}], "/tmp/x.qet") + self.assertIn(f'qetMcpAlign(0, ["{self.A}", "{self.B}"], "center", "")', s) + + class ElementSearch(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() @@ -5560,6 +5629,106 @@ class LayoutIntegration(unittest.TestCase): self.assertFalse(r["ok"]) +@needs_elements +class LayoutOpsIntegration(unittest.TestCase): + """The layout ops on a real QElectroTech. place_element and + align_terminal need terminalPosition(); on a build without it they are + skipped here, and the refusal itself is checked instead.""" + + def setUp(self): + self.sb = Sandbox() + + def tearDown(self): + self.sb.close() + + def placed(self, ops, out="out.qet"): + r = self.sb.edit(self.sb.new(), ops, out=out) + return r + + def xy(self, path): + return {e["uuid"]: (float(e["x"]), float(e["y"])) for e in m.tool_elements(path)["elements"]} + + def add(self, i, x, y, path=COIL): + return {"op": "add_element", "folio": 0, "path": path, "x": x, "y": y, "id": f"e{i}"} + + def test_align_center(self): + r = self.placed([self.add(0, 100, 100), self.add(1, 137, 200), self.add(2, 90, 300), + {"op": "align_elements", "folio": 0, "elements": ["$e0", "$e1", "$e2"], + "edge": "center"}]) + self.assertTrue(r["ok"], r.get("hint")) + self.assertEqual({x for x, _ in self.xy(r["output"]).values()}, {100.0}) + + def test_distribute_evenly_and_by_pitch(self): + r = self.placed([self.add(0, 100, 100), self.add(1, 130, 100), self.add(2, 300, 100), + {"op": "distribute_elements", "folio": 0, + "elements": ["$e0", "$e1", "$e2"], "axis": "horizontal"}]) + self.assertTrue(r["ok"], r.get("hint")) + self.assertEqual(sorted(x for x, _ in self.xy(r["output"]).values()), [100, 200, 300]) + r = self.placed([self.add(0, 100, 100), self.add(1, 100, 130), + {"op": "distribute_elements", "folio": 0, "elements": ["$e0", "$e1"], + "axis": "vertical", "pitch": 80}], out="pitch.qet") + self.assertTrue(r["ok"], r.get("hint")) + self.assertEqual(sorted(y for _, y in self.xy(r["output"]).values()), [100, 180]) + + def need_positions(self, r): + if "terminalPosition" in (r.get("missing_methods") or []): + self.assertFalse(r["ok"]) + self.assertIn("terminalPosition", r["hint"]) + self.skipTest("this build has no terminalPosition()") + + def test_place_element_lines_up_the_terminals(self): + # borne_2's bottom terminal (index 2, local 0,10) over the contact's + # top one (index 0, local 0,-20): 40 px apart, dock to dock + r = self.placed([self.add(0, 100, 100, TERMINAL), + {"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 0, + "next_to": "$e0", "next_to_terminal": 2, "id": "k"}, + {"op": "add_conductor", "folio": 0, "from": "$e0", "from_terminal": 2, + "to": "$k", "to_terminal": 0}]) + self.need_positions(r) + self.assertTrue(r["ok"], r.get("hint")) + placed = r["operations"][1]["result"] + self.assertEqual(self.xy(r["output"])[placed], (100.0, 100 + 10 + 40 + 20)) + self.assertNotIn("note", r["operations"][1]) + + def test_place_element_turned_for_a_rung(self): + # NFPA: turned 270, the block's terminal 2 (its bottom one) faces + # east; the contact placed to its right, turned the same way, then + # meets it with terminal 0 (its top one), facing west + r = self.placed([self.add(0, 100, 100, TERMINAL), + {"op": "rotate_element", "folio": 0, "element": "$e0", "angle": 270}, + {"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 0, + "next_to": "$e0", "next_to_terminal": 2, "angle": 270, "id": "k"}]) + self.need_positions(r) + self.assertTrue(r["ok"], r.get("hint")) + self.assertNotIn("note", r["operations"][2], r["operations"][2]) + x, y = self.xy(r["output"])[r["operations"][2]["result"]] + self.assertEqual(y, 100.0) + + def test_place_element_warns_about_a_terminal_facing_away(self): + r = self.placed([self.add(0, 100, 100, TERMINAL), + {"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 0, + "next_to": "$e0", "next_to_terminal": 1}]) # side terminal + self.need_positions(r) + self.assertTrue(r["ok"], r.get("hint")) + self.assertIn("faces n, not w", r["operations"][1]["note"]) + + def test_place_element_with_no_such_terminal_places_nothing(self): + r = self.placed([self.add(0, 100, 100, TERMINAL), + {"op": "place_element", "folio": 0, "path": SLAVE, "terminal": 9, + "next_to": "$e0", "next_to_terminal": 2}]) + self.need_positions(r) + self.assertFalse(r["ok"]) + self.assertEqual(len(self.xy(r["output"])), 1) + + def test_align_terminal_moves_across_only(self): + r = self.placed([self.add(0, 100, 100, TERMINAL), self.add(1, 127, 200, SLAVE), + {"op": "align_terminal", "folio": 0, "element": "$e1", "terminal": 0, + "to": "$e0", "to_terminal": 2}]) + self.need_positions(r) + self.assertTrue(r["ok"], r.get("hint")) + self.assertIn((100.0, 200.0), self.xy(r["output"]).values()) + + @needs_binary class PlcIntegration(unittest.TestCase): """PLC IO table and PLC-slave linking, against the fixtures in