diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5994bb5..4ff7eae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: - uses: actions/checkout@v4 - name: Fail on non-ASCII bytes in src/*.py run: python3 tools/ci/check_ascii.py + - name: Compile src files with Python 3 + run: python3 tools/ci/compile_python3.py ironpython: name: ironpython @@ -30,3 +32,33 @@ jobs: - name: Import smoke test with stubbed scriptengine shell: pwsh run: .\ipy\net45\ipy.exe tools\ci\import_smoke.py + + # The renderers are destined for src/, so they have to pass under the + # same interpreter CODESYS embeds - not just under CI's Python 3. + - name: Renderer tests under IronPython 2.7 + shell: pwsh + run: | + # A native exe's exit code does not halt a pwsh script, so a failure + # in the first suite would otherwise be masked by the second passing. + .\ipy\net45\ipy.exe tools\ladder\tests\test_ladder.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + .\ipy\net45\ipy.exe tools\ladder\tests\test_fbd.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + .\ipy\net45\ipy.exe tools\ladder\tests\test_export.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # The only host with both XML backends, so the only place their + # equivalence can actually be checked. + .\ipy\net45\ipy.exe tools\ladder\tests\test_xmlbackend.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + ladder: + name: ladder + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Renderer tests under Python 3 + run: | + python3 tools/ladder/tests/test_ladder.py + python3 tools/ladder/tests/test_fbd.py + python3 tools/ladder/tests/test_export.py + python3 tools/ladder/tests/test_xmlbackend.py diff --git a/.gitignore b/.gitignore index b745cbd..015f290 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ .vscode/ +# Claude Code's per-developer tool permissions. Machine-specific paths, and +# permission grants that should not be inherited by whoever clones the repo. +.claude/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/README.md b/README.md index 35dad81..acfd981 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,35 @@ Items are exported in formatted structured text (`.st`) where possible, and in n Actions and Transitions export as `.st` with the kind encoded in the filename (`MyPou.MyAction.action.st`, `MyPou.MyTransition.transition.st`). The file contains the implementation text only, as these objects have no textual declaration. +### Reading graphical POUs + +Ladder and Function Block Diagram POUs have no textual implementation, so they export as native xml that git can store but nobody can review. Alongside that xml, CODESCRIBE writes a `.txt` holding the declaration and a diagram of each network: + +``` +(* Network 2 *) +│ TON_0 : TON CTU_0 : CTU +│ PowerOn ┌───────────────┐ ┌──────────────────────┐ PowerOff +├─────┤ ├────┤IN Q├──┤CU Q├────(R)──────┤ +│ │PT := T#5S ET│ │RESET := PowerOff CV│ +│ └───────────────┘ │PV := 10 │ +│ └──────────────────────┘ +``` + +The declaration is copied from the original CODESYS declaration source, preserving comments, pragmas, safety-qualified types, and literal spelling. The diagram is derived from PLCopen XML. On older CODESYS versions where the plaintext declaration is unavailable, the declaration is rebuilt from the structured interface and the export summary warns that comments, pragmas, or exact formatting may be missing. + +This file is **derived and read-only**. The native xml remains the only thing `Import From Files` reads, so editing the `.txt` changes nothing — it exists to make diffs and code review possible. Layout comes from how the elements are wired, not from their coordinates, so moving a block in the CODESYS editor produces no diff. + +SFC and CFC POUs are not yet rendered; they export as native xml alone. + +Networks are numbered as CODESYS numbers them, so a network in the file lines up with the one in the editor. + +To render an exported PLCopen file by hand, to get plain ASCII instead of box drawing, or to see the equivalent Structured Text (which the export does not write, since showing each network twice in two notations reads worse than showing it once): + +``` +python tools/ladder/render.py --charset ascii MyPou.xml +python tools/ladder/render.py --format st MyPou.xml +``` + Visualisations export as `.vis.xml`, so a `Main` visualisation cannot collide with a `Main` POU. Exports made with older versions of CODESCRIBE use different filenames for some of these objects; they still import correctly, and re-exporting once migrates the tracked files. See [CHANGELOG.md](CHANGELOG.md) for the details. diff --git a/src/charset.py b/src/charset.py new file mode 100644 index 0000000..a0bdd6d --- /dev/null +++ b/src/charset.py @@ -0,0 +1,71 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Drawing characters for the graphical renderers. + +The glyphs are written as \\u escapes rather than literal box-drawing +characters on purpose: CODESYS runs these scripts under IronPython 2.7, which +enforces PEP 263 and refuses to load a source file containing a non-ASCII byte +without an encoding declaration. Escapes keep the source pure ASCII while the +output is Unicode. + +The rendered text is written as UTF-8, matching the .st files CODESCRIBE +already exports. + +An ASCII set is kept alongside for terminals, diff viewers and pasted-into- +email situations where box drawing turns to mojibake. +""" + +from __future__ import unicode_literals + +UNICODE = { + "H": "\u2500", # horizontal wire + "V": "\u2502", # vertical wire + "TL": "\u250c", # box corners + "TR": "\u2510", + "BL": "\u2514", + "BR": "\u2518", + "T_DOWN": "\u252c", # branch leaves downward + "T_UP": "\u2534", + "T_RIGHT": "\u251c", # wire joins and continues right + "T_LEFT": "\u2524", # wire arrives from the left + # A ladder contact is a pair of bars the wire runs between. + "CONTACT_L": "\u2524", + "CONTACT_R": "\u251c", + # Box edges at a pin: the tee marks a real connection, so an unwired pin + # stays a plain wall and is visibly different. + "PIN_L": "\u2524", + "PIN_R": "\u251c", +} + +ASCII = { + "H": "-", + "V": "|", + "TL": "+", + "TR": "+", + "BL": "+", + "BR": "+", + "T_DOWN": "+", + "T_UP": "+", + "T_RIGHT": "+", + "T_LEFT": "+", + "CONTACT_L": "|", + "CONTACT_R": "|", + "PIN_L": "|", + "PIN_R": "|", +} + +SETS = {"unicode": UNICODE, "ascii": ASCII} + +_active = UNICODE + + +def use(name): + """Select the character set by name. Returns the set now in use.""" + global _active + if name not in SETS: + raise ValueError("unknown charset %r, expected one of %s" % (name, ", ".join(sorted(SETS)))) + _active = SETS[name] + return _active + + +def active(): + return _active diff --git a/src/fbd_render.py b/src/fbd_render.py new file mode 100644 index 0000000..03e8382 --- /dev/null +++ b/src/fbd_render.py @@ -0,0 +1,285 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Render a parsed Function Block Diagram as ASCII boxes. + +Layout is derived from the call tree, not from the exported coordinates. Each +block's inputs are rendered to its left and stacked vertically, so a pin fed +by another block gets that block's whole box beside it. Pin rows are placed at +whatever row their source ended up on, which keeps every wire horizontal. +""" + +from __future__ import unicode_literals + +import charset +from layout import Block, stack +from ld_render import render_declaration +from model import Assign, Call, Jump, Label, Signal + + +def _render_signal(node): + return Block([node.text], 0) + + +def _render_label(node): + return Block(["(* label: " + node.name + " *)"], 0) + + +def _render_jump(node): + chars = charset.active() + tail = chars["H"] * 3 + ">> " + (node.target or "?") + if node.condition is None: + return Block([tail], 0) + source = _render(node.condition) + lines = source.padded(source.width) + out = [] + for index, line in enumerate(lines): + out.append(line + tail if index == source.connect_row else line) + return Block(out, source.connect_row) + + +def _render_assign(node): + chars = charset.active() + source = _render(node.source) if node.source is not None else Block([""], 0) + lines = source.padded(source.width) + # The negation circle CODESYS draws on the pin, as an "o" on the wire. + head = "o> " if node.negated else "> " + tail = chars["H"] * 3 + head + (node.label or "?") + out = [] + for index, line in enumerate(lines): + out.append(line + tail if index == source.connect_row else line) + return Block(out, source.connect_row) + + +def _is_wired(source): + """False for a pin CODESYS exported with no source, or an empty expression. + + Those must not be drawn with a wire running off to the left, because there + is nothing out there feeding them. + """ + if source is None: + return False + return not (isinstance(source, Signal) and not source.label) + + +def _render_call(call): + chars = charset.active() + input_blocks = [] + for _pin, source in call.inputs: + input_blocks.append(_render(source) if source is not None else Block([""], 0)) + + left_lines, pin_rows = stack(input_blocks) + # A minimum lead-in, so a source exactly as wide as the column still shows + # a wire and back-to-back boxes do not fuse into one run of borders. + left_width = (max([len(line) for line in left_lines]) + 2) if left_lines else 0 + + # Only the rows where a source hands off to a pin get their wire extended; + # a nested box's own internal wires already end at that box's edge. + handoff = set() + for index, pin_and_source in enumerate(call.inputs): + if _is_wired(pin_and_source[1]): + handoff.add(pin_rows[index]) + + left = [] + for index, line in enumerate(left_lines): + fill = chars["H"] if index in handoff else " " + left.append(line + fill * (left_width - len(line))) + + input_rows = list(pin_rows) + output_rows = [] + for index in range(len(call.outputs)): + if index < len(input_rows): + output_rows.append(input_rows[index]) + else: + # More outputs than inputs: the surplus hangs below the last pin. + base = input_rows[-1] if input_rows else -1 + output_rows.append(base + index - len(input_rows) + 1) + + all_rows = (input_rows + output_rows) or [0] + box_first, box_last = min(all_rows), max(all_rows) + + # The title and top border sit two rows above the first pin, so everything + # shifts down if the first pin would land at the very top of the grid. + shift = max(0, 2 - box_first) + if shift: + left = [" " * left_width] * shift + left + input_rows = [row + shift for row in input_rows] + output_rows = [row + shift for row in output_rows] + box_first += shift + box_last += shift + + in_at = {} + for index, pin_and_source in enumerate(call.inputs): + in_at[input_rows[index]] = pin_and_source[0] or "?" + + out_at = {} + for index, pin_and_assignment in enumerate(call.outputs): + pin, assigned = pin_and_assignment + text = pin or "?" + if assigned: + # =o> is => with the negation bubble: the pin stores its inverse. + text += (" =o> " if pin in call.negated_outputs else " => ") + assigned + elif pin in call.negated_outputs: + text += " o" + out_at[output_rows[index]] = text + + title = call.title + widths = [len(title)] + for row in range(box_first, box_last + 1): + widths.append(len(in_at.get(row, "")) + 3 + len(out_at.get(row, ""))) + inner = max(widths) + + height = max(len(left), box_last + 2) + left += [" " * left_width] * (height - len(left)) + + # handoff was computed before the shift; recompute against the final rows. + handoff_pins = set() + for index, pin_and_source in enumerate(call.inputs): + if _is_wired(pin_and_source[1]): + handoff_pins.add(input_rows[index]) + + # The active output only breaks the box wall with a tee if a consumer is + # actually there to receive it. + pins = [pin for pin, _assigned in call.outputs] + live_output_row = None + if call.output_wired and call.active_output in pins: + live_output_row = output_rows[pins.index(call.active_output)] + + lines = [] + for row in range(height): + if row == box_first - 2: + box = title.center(inner + 2) + elif row == box_first - 1: + box = chars["TL"] + chars["H"] * inner + chars["TR"] + elif row == box_last + 1: + box = chars["BL"] + chars["H"] * inner + chars["BR"] + elif box_first <= row <= box_last: + left_pin = in_at.get(row, "") + right_pin = out_at.get(row, "") + left_edge = chars["PIN_L"] if row in handoff_pins else chars["V"] + right_edge = chars["PIN_R"] if row == live_output_row else chars["V"] + gap = inner - len(left_pin) - len(right_pin) + box = left_edge + left_pin + " " * gap + right_pin + right_edge + else: + box = " " * (inner + 2) + lines.append(left[row] + box) + + # The wire leaves on whichever output pin the consumer asked for. + connect_row = box_first + pins = [pin for pin, _assigned in call.outputs] + if call.active_output in pins: + connect_row = output_rows[pins.index(call.active_output)] + elif output_rows: + connect_row = output_rows[0] + + return Block(lines, connect_row) + + +def _render(node): + if isinstance(node, Call): + return _render_call(node) + if isinstance(node, Assign): + return _render_assign(node) + if isinstance(node, Jump): + return _render_jump(node) + if isinstance(node, Label): + return _render_label(node) + if isinstance(node, Signal): + return _render_signal(node) + raise TypeError("cannot render %r" % (node,)) + + +def _assign_tail(node): + chars = charset.active() + # The negation circle CODESYS draws on the pin, as an "o" on the wire. + return chars["H"] * 2 + ("o " if node.negated else "> ") + (node.label or "?") + + +def _render_fanout(outputs): + """One source driving several outputs: draw it once and branch. + + This is how CODESYS shows it, and drawing the box once per output would + both misrepresent the program and double the width of the diff. + """ + chars = charset.active() + source = _render(outputs[0].source) + # A short lead before the junction, so the branch is not welded to the box + # edge. padded() extends the wire row and pads the rest with spaces. + width = source.width + 2 + lines = source.padded(width) + + rows = [source.connect_row + index for index in range(len(outputs))] + while len(lines) <= rows[-1]: + lines.append(" " * width) + + first, last = rows[0], rows[-1] + out = [] + for row, line in enumerate(lines): + if row == first: + joint = chars["T_DOWN"] if len(rows) > 1 else chars["H"] + elif row == last: + joint = chars["BL"] + elif row in rows: + joint = chars["T_RIGHT"] + elif first < row < last: + joint = chars["V"] + else: + joint = " " + tail = _assign_tail(outputs[rows.index(row)]) if row in rows else "" + out.append(line + joint + tail) + + return Block(out, first) + + +def _shared_source(outputs): + """The single source every output hangs off, or None. + + Identity, not equality: the parser memoises shared nodes, so two outputs + fed by one block hold the very same object. + """ + if len(outputs) < 2: + return None + if not all(isinstance(output, Assign) for output in outputs): + return None + first = outputs[0].source + if first is None: + return None + return first if all(output.source is first for output in outputs) else None + + +def render_network(network): + """Render one network, which may drive several outputs from one source.""" + outputs = getattr(network, "outputs", [network]) + + if _shared_source(outputs) is not None: + return _render_fanout(outputs).lines + + lines = [] + for tree in outputs: + lines.extend(_render(tree).lines) + # An EXECUTE box's body is the logic; drawing the box without it would + # be an empty rectangle where a dozen lines of ST should be. + if isinstance(tree, Call) and tree.st_code: + lines = lines + [""] + [" " + line for line in tree.st_code] + return lines + + +def render_pou(pou): + """Render a whole FBD POU: declaration, then one box tree per network.""" + lines = render_declaration(pou) + lines.append("") + + if not pou.networks: + lines.append("(* no networks *)") + + for index, network in enumerate(pou.networks): + header = "(* Network " + str(index + 1) + if network.comment: + comment = network.comment.replace("\r", " ").replace("\n", " ").replace("*)", "* )") + header += ": " + comment.lstrip("/").strip() + lines.append(header + " *)") + lines.extend(render_network(network)) + lines.append("") + + while lines and lines[-1] == "": + lines.pop() + + return [line.rstrip() for line in lines] diff --git a/src/graphical_export.py b/src/graphical_export.py new file mode 100644 index 0000000..6b65013 --- /dev/null +++ b/src/graphical_export.py @@ -0,0 +1,238 @@ +# REMEMBER: this is python 2.7 +"""Write a human-readable rendering of a graphical POU alongside its native xml. + +Graphical POUs (LD, FBD, SFC, CFC) have no textual implementation, so they +export as CODESYS native xml, which git can store but nobody can review. This +adds a derived .txt next to it: the declaration and a diagram per network. + +The .txt is READ-ONLY as far as CODESCRIBE is concerned. The native xml stays +the only thing Import From Files reads, so the round trip is unaffected and +editing the .txt achieves nothing. import_from_files dispatches on ".xml" and +".st", so a ".txt" is ignored by construction. + +The rendering goes through PLCopen xml rather than the native format, because +PLCopen has a published schema for graphical bodies while the native format +does not. +""" + +import os +import tempfile +import time + +import fbd_render +import ld_render +import parse_fbd +import parse_ld +import plcopen +from util import open_utf8 + +# Suffix for the derived file. Deliberately not .st: these are not importable +# and must never be mistaken for source. +RENDERED_SUFFIX = ".txt" + +# Rendering adds a second CODESYS-side export per graphical POU, so the cost +# is worth reporting rather than leaving people to wonder why the export got +# slower. Split so it is obvious whether CODESYS or this code is the cost. +EMPTY_STATS = { + "rendered": 0, + "skipped": 0, + "export_xml_seconds": 0.0, + "parse_seconds": 0.0, + "draw_seconds": 0.0, + "verbatim_declarations": 0, + "fallback_declarations": 0, +} + +STATS = dict(EMPTY_STATS) + + +def reset_stats(): + STATS.update(EMPTY_STATS) + + +def summary(): + """One line describing what rendering cost, or None if it did nothing. + + Split three ways because the first measurement overturned the guess: the + CODESYS-side export turned out to be a rounding error next to this code, + and "rendering" as a single figure does not say whether that is the XML + parser or the layout. + """ + if not STATS["rendered"] and not STATS["skipped"]: + return None + total = STATS["export_xml_seconds"] + STATS["parse_seconds"] + STATS["draw_seconds"] + line = "Rendered %d graphical POUs in %.1fs (%.1fs CODESYS export_xml, %.1fs parsing, %.1fs drawing); skipped %d" % ( + STATS["rendered"], + total, + STATS["export_xml_seconds"], + STATS["parse_seconds"], + STATS["draw_seconds"], + STATS["skipped"], + ) + # Falling back to the rebuilt declaration is silent otherwise, and it + # costs every comment, pragma and attribute in the file. Say so. + if STATS["fallback_declarations"]: + line += "\n NOTE: %d POU declaration(s) were rebuilt from structured XML; comments," % STATS[ + "fallback_declarations" + ] + line += " pragmas and attributes may be missing from those declarations." + return line + + +# Body language -> (parser, diagram renderer). SFC and CFC are absent, so they +# fall through and no file is written for them. +RENDERERS = { + parse_ld.LANGUAGE: (parse_ld, ld_render), + parse_fbd.LANGUAGE: (parse_fbd, fbd_render), +} + + +def _render_pous(plcopen_path): + """(pou, art_renderer) for every POU in the file we know how to draw. + + One pass over the document. Asking each language parser in turn would + re-read and re-parse the whole file once per language, which is pure waste + on a project with hundreds of POUs. + """ + found = [] + for pou_elem, language, body in plcopen.iter_bodies(plcopen_path): + entry = RENDERERS.get(language) + if entry is None: + continue + parser, art_renderer = entry + found.append((parser.pou_from_body(pou_elem, body), art_renderer)) + return found + + +def render_plcopen(plcopen_path, declaration_text=None): + """Render every renderable POU in a PLCopen file. [] if there are none. + + The declaration and the diagram only. An equivalent-ST rendering was + written alongside these at first, but showing the same network twice in + two notations made the files harder to read rather than easier. The ST + emitter is still there and reachable from tools/ladder/render.py for + anyone who wants it; it is just not what the export writes. + """ + started = time.time() + pous = _render_pous(plcopen_path) + if declaration_text is not None and pous: + pous[0][0].declaration_text = declaration_text.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") + STATS["parse_seconds"] += time.time() - started + + started = time.time() + lines = [] + for pou, art_renderer in pous: + if pou.declaration_text: + STATS["verbatim_declarations"] += 1 + else: + STATS["fallback_declarations"] += 1 + lines.extend(art_renderer.render_pou(pou)) + lines.append(u"") + + while lines and lines[-1] == u"": + lines.pop() + STATS["draw_seconds"] += time.time() - started + return lines + + +# Ways of asking for plaintext declarations, most likely to bind first. +# ScriptEngine methods are .NET overloads, and IronPython resolves them by +# signature: keyword arguments frequently fail to bind where the same call +# positionally succeeds. The documented overload is +# export_xml(path, recursive, export_folder_structure, declarations_as_plaintext). +_EXPORT_ATTEMPTS = ( + lambda obj, path: obj.export_xml(path, False, False, True), + lambda obj, path: obj.export_xml(path=path, recursive=False, declarations_as_plaintext=True), + lambda obj, path: obj.export_xml(None, path, False, False, True), +) + + +def _export_plcopen(obj, path): + """Export one object as PLCopen xml, asking for plaintext declarations. + + The structured has nowhere to put a comment, a pragma or an + attribute, so without this the declaration in the rendering silently drops + all three. CODESYS documents the flag as lossless. + + It is a proprietary extension and an overload this ScriptEngine build may + not have, so a TypeError - which is what IronPython raises when no + overload matches - falls back to the plain call rather than losing the + rendering altogether. + """ + for attempt in _EXPORT_ATTEMPTS: + try: + attempt(obj, path) + return + except TypeError: + # No matching overload on this build. Try the next shape. + continue + # Nothing with plaintext bound, so fall back to the lossy declaration + # rather than losing the rendering. + obj.export_xml(path=path, recursive=False) + + +def _remove_quietly(path): + """Best-effort delete. Cleanup trouble is never worth failing an export.""" + try: + if os.path.exists(path): + os.remove(path) + except Exception: + pass + + +def write_rendered_text(obj, base_path): + """Export obj as PLCopen xml, render it, and write .txt. + + Returns True if a file was written. SFC and CFC bodies parse to nothing + renderable, so they are skipped rather than producing an empty file. + + A rendering failure must not fail the export: the native xml has already + been written and is complete and correct on its own. The problem is + reported and the export carries on. That barrier has to hold around the + temp-file scaffolding too, not just the rendering itself - a full %TEMP% + or an antivirus scan holding the temp file open must degrade to a warning + exactly like a parse failure does. + """ + # Staged outside the export folder: exports are written to a staging + # directory that gets swapped into place wholesale, and a temp file left + # behind by a failed cleanup would be swapped in along with it. + try: + handle, temp_path = tempfile.mkstemp(suffix=".plcopen.xml") + os.close(handle) + except Exception as error: + print("WARNING: could not render " + obj.get_name() + ": " + repr(error)) + return False + try: + started = time.time() + _export_plcopen(obj, temp_path) + STATS["export_xml_seconds"] += time.time() - started + + # render_plcopen accounts for its own parse and draw time. + textual_declaration = getattr(getattr(obj, "textual_declaration", None), "text", None) + lines = render_plcopen(temp_path, textual_declaration) + if not lines: + STATS["skipped"] += 1 + return False + + with open_utf8(base_path + RENDERED_SUFFIX, "w") as f: + f.write(u"\n".join(lines)) + f.write(u"\n") + STATS["rendered"] += 1 + return True + except Exception as error: + print("WARNING: could not render " + obj.get_name() + ": " + repr(error)) + # Say what is actually in the file, so a failure explains itself + # instead of needing a separate diagnostic run. The diagnostic is + # best-effort: it must not turn a reported failure into a raised one. + try: + if os.path.exists(temp_path): + for note in plcopen.describe_suspect_characters(temp_path): + print(" " + note) + except Exception: + pass + # A write that died halfway leaves a truncated rendering that looks + # exactly like a valid one. No file at all is the honest outcome. + _remove_quietly(base_path + RENDERED_SUFFIX) + return False + finally: + _remove_quietly(temp_path) diff --git a/src/import_export.py b/src/import_export.py index e9879e1..0d72d04 100644 --- a/src/import_export.py +++ b/src/import_export.py @@ -4,6 +4,7 @@ import scriptengine # type: ignore +from graphical_export import write_rendered_text from object_type import ObjectType, get_object_type from util import * @@ -101,6 +102,8 @@ def export_pou(child_obj, parent_obj, parent_folder_path, export_child_fn): write_st(child_obj, f) else: export_native(child_obj, parent_obj, parent_folder_path, export_child_fn) + # Derived, review-only. The native xml above stays the import source. + write_rendered_text(child_obj, os.path.join(parent_folder_path, child_obj.get_name())) for c in child_obj.get_children(): export_child_fn(c, child_obj, parent_folder_path) @@ -200,11 +203,9 @@ def export_method(child_obj, parent_obj, parent_folder_path, export_child_fn): ) as f: write_st(child_obj, f) else: - write_native( - child_obj, - os.path.join(parent_folder_path, parent_obj.get_name() + "." + child_obj.get_name() + ".xml"), - recursive=False, - ) + base = os.path.join(parent_folder_path, parent_obj.get_name() + "." + child_obj.get_name()) + write_native(child_obj, base + ".xml", recursive=False) + write_rendered_text(child_obj, base) def import_method_st(child, dir_path, dir_parent_obj, import_dir_fn): @@ -232,6 +233,7 @@ def _export_member_st_or_xml(child_obj, parent_obj, parent_folder_path, st_suffi f.write(child_obj.textual_implementation.text) else: write_native(child_obj, base + ".xml", recursive=False) + write_rendered_text(child_obj, base) def export_action(child_obj, parent_obj, parent_folder_path, export_child_fn): diff --git a/src/layout.py b/src/layout.py new file mode 100644 index 0000000..f1dff67 --- /dev/null +++ b/src/layout.py @@ -0,0 +1,49 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Text-grid composition shared by the graphical renderers. + +A Block is a rectangle of text plus the row its wire enters and leaves on. +Renderers build small Blocks for leaves and compose them; nothing else needs +to know about absolute coordinates. +""" + +from __future__ import unicode_literals + +import charset + + +class Block(object): + def __init__(self, lines, connect_row): + self.lines = lines + self.connect_row = connect_row + + @property + def width(self): + if not self.lines: + return 0 + return max(len(line) for line in self.lines) + + def padded(self, width, wire_rows=None, fill=None): + """Lines padded to ``width``, extending wires horizontally. + + Rows listed in ``wire_rows`` (defaulting to this Block's own connect + row) are filled with the wire character so a short branch still reaches + the junction on its right. Every other row is filled with spaces. + """ + if wire_rows is None: + wire_rows = set([self.connect_row]) + if fill is None: + fill = charset.active()["H"] + out = [] + for index, line in enumerate(self.lines): + out.append(line + (fill if index in wire_rows else " ") * (width - len(line))) + return out + + +def stack(blocks): + """Stack Blocks vertically. Returns (lines, absolute connect rows).""" + lines = [] + connect_rows = [] + for block in blocks: + connect_rows.append(len(lines) + block.connect_row) + lines.extend(block.lines) + return lines, connect_rows diff --git a/src/ld_render.py b/src/ld_render.py new file mode 100644 index 0000000..9875af4 --- /dev/null +++ b/src/ld_render.py @@ -0,0 +1,292 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Render a parsed Ladder Diagram as rungs. + +Layout comes from the expression tree only - the x/y coordinates in the source +XML are deliberately ignored. Dragging a contact sideways in CODESYS must not +show up as a diff. + +Composition works on Blocks: a rectangle of text plus the row index its wire +enters and leaves on. Series concatenates Blocks horizontally aligned on that +row; Parallel stacks them and threads a junction column down each side. + +Drawing characters come from charset, so the same layout renders as either +box-drawing Unicode or plain ASCII. +""" + +from __future__ import unicode_literals + +import charset +from layout import Block +from model import BLOCK, COIL, CONTACT, Element, Empty, Parallel, Series + +POU_TYPE_KEYWORDS = { + "program": "PROGRAM", + "functionBlock": "FUNCTION_BLOCK", + "function": "FUNCTION", +} + + +def _symbol_and_label(element): + """The drawn symbol, and the caption sitting above it.""" + chars = charset.active() + kind = element.kind + + if kind == CONTACT: + if element.edge == "rising": + middle = "P" + elif element.edge == "falling": + middle = "N" + elif element.negated: + middle = "/" + else: + middle = " " + return chars["CONTACT_L"] + middle + chars["CONTACT_R"], element.label or "" + + if kind == COIL: + if element.storage == "set": + middle = "S" + elif element.storage == "reset": + middle = "R" + elif element.negated: + middle = "/" + else: + middle = " " + return "(" + middle + ")", element.label or "" + + if kind == "jump": + return ">>" + (element.label or "?"), "" + + if kind == "return": + return "", "" + + if kind == "label": + # A jump target: a marker in the rung order, not a symbol on a wire. + return (element.label or "?") + ":", "" + + # In/out variables and anything unrecognised draw as a named box so + # unhandled logic is visible rather than silently dropped. A negated + # variable spells its NOT out - there is no bubble to draw on a box. + label = element.label or "?" + if element.negated: + label = "NOT " + label + return "[" + label + "]", "" + + +def _render_block(element): + """Draw a function block as a pin box. + + The power pin sorts first, so the wire enters and leaves on the same row. + Only pins that are genuinely wired get a tee on the box edge; a + parameterised or unconsumed pin leaves the wall unbroken. + """ + chars = charset.active() + + left = [] + wired = [] + for pin, label in element.input_pins: + text = pin or "?" + # A label of None is the power pin - it is wired, not parameterised. + if label is not None: + text += " := " + label if label else "" + left.append(text) + wired.append(label is None) + + right = [] + for pin, assigned in element.output_pins: + text = pin or "?" + wired_out = element.output_wired and pin == element.active_output + if assigned: + # =o> is => with the negation bubble: the pin stores its inverse. + text += (" =o> " if pin in element.negated_outputs else " => ") + assigned + elif pin in element.negated_outputs and not wired_out: + # A wired pin draws its bubble on the box edge instead - one + # bubble, not two. + text += " o" + right.append(text) + + rows = max(len(left), len(right), 1) + left += [""] * (rows - len(left)) + wired += [False] * (rows - len(wired)) + right += [""] * (rows - len(right)) + + title = element.title + inner = max([len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)]) + + lines = [title.center(inner + 2)] + lines.append(chars["TL"] + chars["H"] * inner + chars["TR"]) + for index in range(rows): + gap = inner - len(left[index]) - len(right[index]) + left_edge = chars["PIN_L"] if wired[index] else chars["V"] + if wired[index] and element.power_negated: + # The negation bubble on the power pin, drawn on the box wall. + left_edge = "o" + # Only the active output continues onward, and only if consumed. + right_edge = chars["PIN_R"] if (index == 0 and element.output_wired) else chars["V"] + if index == 0 and element.output_wired and element.active_output in element.negated_outputs: + right_edge = "o" + lines.append(left_edge + left[index] + " " * gap + right[index] + right_edge) + lines.append(chars["BL"] + chars["H"] * inner + chars["BR"]) + + # Row 0 is the title and row 1 the top border, so the first pin is row 2. + connect_row = 2 + + # A lead-in and lead-out stub, so back-to-back boxes do not fuse into one + # unreadable run of border characters. + stubbed = [] + for index, line in enumerate(lines): + stub = chars["H"] if index == connect_row else " " + stubbed.append(stub + line + stub) + + return Block(stubbed, connect_row) + + +def _render_element(element): + if element.kind == BLOCK: + return _render_block(element) + + chars = charset.active() + symbol, label = _symbol_and_label(element) + width = max(len(label) + 2, len(symbol) + 4) + + lead = (width - len(symbol)) // 2 + symbol_line = chars["H"] * lead + symbol + chars["H"] * (width - len(symbol) - lead) + + lead = (width - len(label)) // 2 + label_line = " " * lead + label + " " * (width - len(label) - lead) + + return Block([label_line, symbol_line], 1) + + +def _render_series(items): + blocks = [_render(item) for item in items] + connect_row = max(block.connect_row for block in blocks) + height = max(connect_row - block.connect_row + len(block.lines) for block in blocks) + + columns = [] + for block in blocks: + width = block.width + above = connect_row - block.connect_row + lines = [" " * width] * above + lines += [line.ljust(width) for line in block.lines] + lines += [" " * width] * (height - len(lines)) + columns.append(lines) + + joined = [] + for row in range(height): + joined.append("".join(column[row] for column in columns)) + return Block(joined, connect_row) + + +def _render_parallel(branches): + chars = charset.active() + blocks = [_render(branch) for branch in branches] + width = max(block.width for block in blocks) + + stacked = [] + connect_rows = [] + for block in blocks: + connect_rows.append(len(stacked) + block.connect_row) + for index, line in enumerate(block.lines): + # The wire itself extends horizontally; everything else with + # spaces, so short branches still reach the junction on the right. + fill = chars["H"] if index == block.connect_row else " " + stacked.append(line + fill * (width - len(line))) + + junctions = set(connect_rows) + first, last = connect_rows[0], connect_rows[-1] + + lines = [] + for row, line in enumerate(stacked): + if row == first: + # The main line carries straight on and drops a branch downward. + left, right = chars["T_DOWN"], chars["T_DOWN"] + elif row == last: + left, right = chars["BL"], chars["BR"] + elif row in junctions: + left, right = chars["T_RIGHT"], chars["T_LEFT"] + elif first < row < last: + left = right = chars["V"] + else: + left = right = " " + lines.append(left + line + right) + + return Block(lines, first) + + +def _render(expr): + chars = charset.active() + if isinstance(expr, Empty): + return Block([" ", chars["H"] * 3], 1) + if isinstance(expr, Element): + return _render_element(expr) + if isinstance(expr, Series): + return _render_series(expr.items) + if isinstance(expr, Parallel): + return _render_parallel(expr.branches) + raise TypeError("cannot render %r" % (expr,)) + + +def render_rung(expr): + """Render one rung, bounded by the power rails.""" + chars = charset.active() + block = _render(expr) + lines = [] + for row, line in enumerate(block.lines): + if row == block.connect_row: + lines.append(chars["T_RIGHT"] + chars["H"] * 2 + line + chars["H"] * 2 + chars["T_LEFT"]) + else: + lines.append(chars["V"] + " " + line) + return lines + + +def render_declaration(pou): + """The POU's declaration. + + Verbatim when CODESYS gave us the plaintext version, because that is the + only form carrying comments, pragmas and attributes - and a pragma like + {attribute 'qualified_only'} changes what the code means, so paraphrasing + it away is worse than not showing it. Otherwise rebuilt from the + structured interface, which is all older exports offer. + """ + if pou.declaration_text: + return pou.declaration_text.split("\n") + + keyword = POU_TYPE_KEYWORDS.get(pou.pou_type, "PROGRAM") + lines = [keyword + " " + pou.name] + + scope = None + for variable in pou.variables: + if variable.scope != scope: + if scope is not None: + lines.append("END_VAR") + lines.append(variable.scope) + scope = variable.scope + entry = " " + variable.name + " : " + variable.type_name + if variable.initial_value is not None: + entry += " := " + variable.initial_value + lines.append(entry + ";") + if scope is not None: + lines.append("END_VAR") + + return lines + + +def render_pou(pou): + """Render a whole POU: declaration, then one block per rung.""" + lines = render_declaration(pou) + lines.append("") + + if not pou.rungs: + lines.append("(* no rungs *)") + + for index, rung in enumerate(pou.rungs): + lines.append("(* Network " + str(index + 1) + " *)") + lines.extend(render_rung(rung)) + lines.append("") + + while lines and lines[-1] == "": + lines.pop() + + # Trailing whitespace is an artefact of grid composition, and the repo's + # pre-commit hooks would strip it anyway. + return [line.rstrip() for line in lines] diff --git a/src/model.py b/src/model.py new file mode 100644 index 0000000..1d1126b --- /dev/null +++ b/src/model.py @@ -0,0 +1,381 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Data model for a Ladder Diagram network. + +Two layers live here: + +* ``Node`` - a single graphical element exactly as it appears in the PLCopen + body, still wired by ``localId``. This is a faithful, dumb transcription of + the XML. +* The ``Expr`` classes - the same logic rearranged into the series/parallel + tree that a renderer can actually draw. Coordinates are deliberately dropped + at this point: layout is derived from topology so that nudging a block in the + CODESYS editor does not churn the diff. +""" + +import re + +# A bare identifier, member access or literal - something safe to negate or +# nest without brackets. Anything else (operators, calls, spaces) must be +# parenthesised: expressions are free-form ST, often typed without spaces, +# and NOT binds above comparison in IEC 61131-3, so "NOT iCount>5" states +# "(NOT iCount)>5". The % covers direct addresses like %IX0.0. +_SIMPLE_TERM = re.compile(r"^[A-Za-z0-9_.#%]+$") + + +def is_simple_term(text): + """True when text can be negated or nested without changing its grouping.""" + return _SIMPLE_TERM.match(text) is not None + + +# Element kinds we understand. Anything else is carried through as an opaque +# element so unknown logic is visibly wrong rather than silently missing. +LEFT_RAIL = "leftPowerRail" +RIGHT_RAIL = "rightPowerRail" +CONTACT = "contact" +COIL = "coil" +BLOCK = "block" +IN_VARIABLE = "inVariable" +OUT_VARIABLE = "outVariable" +JUMP = "jump" +RETURN = "return" +LABEL = "label" + +RAILS = (LEFT_RAIL, RIGHT_RAIL) + + +class Connection(object): + """One wire arriving at an element. + + ``source_pin`` is the formalParameter on the *upstream* element's output + (CODESYS writes ``formalParameter="Q"`` on the connection itself), while + ``target_pin`` is the input pin on *this* element. Only blocks have named + pins; for contacts and coils both are None. + + ``negated`` is the bubble CODESYS draws on the *pin itself* (negated="true" + on the pin's variable element) - separate from a negated inVariable, and + just as logic-inverting if dropped. + """ + + def __init__(self, ref_id, source_pin=None, target_pin=None, negated=False): + self.ref_id = ref_id + self.source_pin = source_pin + self.target_pin = target_pin + self.negated = negated + + def __repr__(self): + return "Connection(%s, source_pin=%r, target_pin=%r)" % (self.ref_id, self.source_pin, self.target_pin) + + +class Node(object): + """One graphical element from an LD body, still wired by localId.""" + + def __init__( + self, + local_id, + kind, + label=None, + negated=False, + edge=None, + storage=None, + inputs=None, + type_name=None, + instance_name=None, + outputs=None, + st_code=None, + negated_outputs=None, + ): + self.st_code = st_code if st_code is not None else [] # blocks only: inline ST + self.local_id = local_id + self.kind = kind + self.label = label + self.negated = negated + self.edge = edge # "rising" | "falling" | None + self.storage = storage # "set" | "reset" | None + self.inputs = inputs if inputs is not None else [] + self.type_name = type_name # blocks only + self.instance_name = instance_name # blocks only, absent for operators + self.outputs = outputs if outputs is not None else [] # blocks only: (pin, assigned_var) + # blocks only: output pins whose in-place negation bubble inverts the + # value leaving them + self.negated_outputs = negated_outputs if negated_outputs is not None else set() + + def __repr__(self): + return "Node(%s, %s, %r, inputs=%r)" % (self.local_id, self.kind, self.label, self.inputs) + + +class Variable(object): + """One entry from the POU interface, for rendering the declaration block.""" + + def __init__(self, name, type_name, initial_value=None, scope="VAR"): + self.name = name + self.type_name = type_name + self.initial_value = initial_value + self.scope = scope + + +class Pou(object): + """A parsed POU. ``rungs`` is populated for LD, ``networks`` for FBD.""" + + def __init__( + self, name, pou_type, variables=None, rungs=None, networks=None, language=None, declaration_text=None + ): + self.name = name + self.pou_type = pou_type + self.language = language + # The declaration exactly as CODESYS wrote it, comments, pragmas and + # attributes included. None when the export did not carry one, in + # which case it gets rebuilt from `variables` and loses all three. + self.declaration_text = declaration_text + self.variables = variables if variables is not None else [] + self.rungs = rungs if rungs is not None else [] + self.networks = networks if networks is not None else [] + + +# --- FBD tree -------------------------------------------------------------- +# +# FBD has no power rail, so there is no single wire to hang a series/parallel +# tree off. A network is instead a tree of calls: each block pin is fed either +# by a named value or by another block's output. + + +class Signal(object): + """A named value entering a network: a variable, a literal, or nothing. + + CODESYS can negate an inVariable in place, which is easy to miss and + inverts the logic if it is dropped. + """ + + def __init__(self, label, negated=False): + self.label = label + self.negated = negated + + @property + def text(self): + label = self.label or "" + if not self.negated: + return label + # A compound expression must keep its parentheses or the logic + # regroups - see is_simple_term for the precedence trap. + if is_simple_term(label): + return "NOT " + label + return "NOT (" + label + ")" + + def __repr__(self): + return "Signal(%r, negated=%r)" % (self.label, self.negated) + + +class Jump(object): + """A conditional jump to a label. Terminates its network.""" + + def __init__(self, target, condition=None): + self.target = target + self.condition = condition + + def __repr__(self): + return "Jump(%r)" % (self.target,) + + +class Label(object): + """A jump target. Marks a point in the network order, carries no logic.""" + + def __init__(self, name): + self.name = name + + def __repr__(self): + return "Label(%r)" % (self.name,) + + +class Call(object): + """An FBD block call - a box with named input and output pins. + + ``inputs`` is [(pin_name, source)] where source is a Call, a Signal or + None. ``outputs`` is [(pin_name, assigned_variable)]. Pin order is kept + exactly as exported; unlike LD there is no power pin to hoist. + """ + + def __init__( + self, + type_name=None, + instance_name=None, + inputs=None, + outputs=None, + active_output=None, + output_wired=False, + st_code=None, + negated_outputs=None, + ): + self.type_name = type_name + self.instance_name = instance_name + self.inputs = inputs if inputs is not None else [] + self.outputs = outputs if outputs is not None else [] + self.active_output = active_output + # Pins carrying CODESYS's in-place negation bubble: the value leaving + # them is the inverse of the pin. + self.negated_outputs = negated_outputs if negated_outputs is not None else set() + # An EXECUTE box carries inline ST as its whole body. Dropping it loses + # the logic entirely while still drawing a plausible-looking box. + self.st_code = st_code if st_code is not None else [] + # True when something downstream consumes the active output. A network + # sink has an active output but nothing to hand it to. + self.output_wired = output_wired + + @property + def title(self): + if self.instance_name: + return self.instance_name + " : " + (self.type_name or "?") + return self.type_name or "?" + + @property + def is_operator(self): + """Operators and functions have no instance, so they inline as expressions.""" + return not self.instance_name + + def __repr__(self): + return "Call(%r, %r)" % (self.type_name, self.instance_name) + + +class Network(object): + """One FBD network: a comment, and the outputs its logic drives. + + A network can drive several outputs from shared logic - CODESYS draws that + as one box with the wire branching. Treating each output as its own + network duplicates the shared expression and makes the numbering disagree + with the editor, which is what a reviewer compares against. + """ + + def __init__(self, comment="", outputs=None): + self.comment = comment + self.outputs = outputs if outputs is not None else [] + + def __repr__(self): + return "Network(%r, %d outputs)" % (self.comment, len(self.outputs)) + + +class Assign(object): + """An outVariable: a network whose result is stored into a variable. + + Like an inVariable, CODESYS can negate the pin in place - and dropping + that inverts the stored value. + """ + + def __init__(self, label, source=None, negated=False): + self.label = label + self.source = source + self.negated = negated + + def __repr__(self): + return "Assign(%r, negated=%r)" % (self.label, self.negated) + + +# --- expression tree ------------------------------------------------------- + + +class Empty(object): + """A wire with nothing on it - an unconditional rung, or a bare rail.""" + + def __eq__(self, other): + return isinstance(other, Empty) + + def __repr__(self): + return "Empty()" + + +class Element(object): + """A drawable leaf: contact, coil, block call, jump. + + For blocks, ``input_pins`` and ``output_pins`` are lists of + ``(pin_name, label)``. A label of None marks the pin carrying power flow - + the one wired into the rung rather than fed from a literal or a side + branch. That pin is always sorted first so the wire runs straight through. + """ + + def __init__( + self, + kind, + label=None, + negated=False, + edge=None, + storage=None, + type_name=None, + instance_name=None, + input_pins=None, + output_pins=None, + active_output=None, + output_wired=False, + power_negated=False, + negated_outputs=None, + ): + self.kind = kind + self.label = label + self.negated = negated + self.edge = edge + self.storage = storage + self.type_name = type_name + self.instance_name = instance_name + self.input_pins = input_pins if input_pins is not None else [] + self.output_pins = output_pins if output_pins is not None else [] + self.active_output = active_output + # Blocks only: the negation bubble on the pin the rung's power enters + # through, and the set of output pins carrying one. Both invert the + # logic in place if dropped. + self.power_negated = power_negated + self.negated_outputs = negated_outputs if negated_outputs is not None else set() + # True when something downstream actually consumes the active output, + # so the renderer knows whether to break the box edge with a tee. + self.output_wired = output_wired + + @property + def title(self): + """Caption drawn above a block: 'TON_0 : TON', or just 'GT'.""" + if self.instance_name: + return self.instance_name + " : " + (self.type_name or "?") + return self.type_name or self.label or "?" + + def __repr__(self): + return "Element(%s, %r)" % (self.kind, self.label) + + +class Series(object): + """Elements wired left to right - logical AND.""" + + def __init__(self, items): + self.items = items + + def __repr__(self): + return "Series(%r)" % (self.items,) + + +class Parallel(object): + """Branches wired top to bottom - logical OR.""" + + def __init__(self, branches): + self.branches = branches + + def __repr__(self): + return "Parallel(%r)" % (self.branches,) + + +def series(items): + """Build a Series, flattening nested ones and dropping Empty legs.""" + flat = [] + for item in items: + if isinstance(item, Empty): + continue + if isinstance(item, Series): + flat.extend(item.items) + else: + flat.append(item) + if not flat: + return Empty() + if len(flat) == 1: + return flat[0] + return Series(flat) + + +def parallel(branches): + """Build a Parallel, collapsing the single-branch case.""" + if not branches: + return Empty() + if len(branches) == 1: + return branches[0] + return Parallel(branches) diff --git a/src/parse_fbd.py b/src/parse_fbd.py new file mode 100644 index 0000000..0c44967 --- /dev/null +++ b/src/parse_fbd.py @@ -0,0 +1,284 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Parse Function Block Diagram bodies out of PLCopen XML. + +FBD has no power rail, so networks are found the same way rungs are - by +looking for sinks nothing else consumes - but the result is a tree of calls +rather than a series/parallel chain. +""" + +from model import BLOCK, Assign, Call, Jump, Label, Network, Node, Pou, Signal +from plcopen import ( + block_connections, + block_outputs, + block_st_code, + child_text, + declaration_text, + comment_text, + direct_connections, + find_child, + is_true, + iter_bodies, + negated_output_pins, + parse_interface, + tag, +) + +COMMENT = "comment" +IN_VARIABLE = "inVariable" +OUT_VARIABLE = "outVariable" +JUMP = "jump" +LABEL = "label" +RETURN = "return" +CONNECTOR = "connector" +CONTINUATION = "continuation" + +# vendorElement carries CODESYS editor state (network titles, implementation +# attributes) and holds no logic, so it is skipped entirely. +FBD_KINDS = (BLOCK, IN_VARIABLE, OUT_VARIABLE, COMMENT, JUMP, RETURN, LABEL, CONTINUATION, CONNECTOR) + +# Elements that can terminate a network. A jump or return ends one just as +# surely as an assignment does - leaving them out drops the entire guard +# network they belong to, silently. A connector too: its continuations refer +# to it by name, never by localId, so nothing ever "consumes" it and without +# a sink entry its whole upstream network would vanish. +SINK_KINDS = (BLOCK, OUT_VARIABLE, JUMP, RETURN, LABEL, CONNECTOR) + + +def parse_fbd_body(body_elem): + """Return an ordered list of Nodes from an body element.""" + nodes = [] + for child in body_elem: + kind = tag(child) + if kind not in FBD_KINDS: + continue + local_id = child.get("localId") + if local_id is None: + continue + + if kind == COMMENT: + nodes.append(Node(local_id=local_id, kind=COMMENT, label=comment_text(child))) + continue + + is_block = kind == BLOCK + if is_block: + label = child.get("instanceName") or child.get("typeName") + elif kind in (JUMP, LABEL): + # Both carry their target in a "label" attribute, not a child. + label = child.get("label") + elif kind in (CONNECTOR, CONTINUATION): + # The wire's name is a "name" attribute; there is no expression. + label = child.get("name") + else: + label = child_text(child, "expression") + + node = Node( + local_id=local_id, + kind=kind, + label=label, + negated=is_true(child, "negated"), + inputs=block_connections(child) if is_block else direct_connections(child), + type_name=child.get("typeName") if is_block else None, + instance_name=child.get("instanceName") if is_block else None, + outputs=block_outputs(child) if is_block else None, + st_code=block_st_code(child) if is_block else None, + negated_outputs=negated_output_pins(child) if is_block else None, + ) + nodes.append(node) + return nodes + + +def _negate(source): + """Wrap a pin's source in the negation its pin bubble demands. + + A Signal simply flips; anything else becomes an explicit NOT operator so + the inversion is visible in both the ST and the diagram. + """ + if isinstance(source, Signal): + return Signal(source.label, negated=not source.negated) + return Call( + type_name="NOT", + inputs=[("In", source)], + outputs=[("Out", None)], + active_output="Out", + output_wired=True, + ) + + +def _build(node, by_id, visiting, via_pin=None, memo=None): + """Build the tree feeding a node. + + Results are memoised on (localId, pin) so a block feeding two outputs + yields the same object to both, which is what lets the renderers draw one + box with a branch instead of two identical boxes. + """ + if memo is None: + memo = {} + key = (node.local_id, via_pin) + if key not in memo: + memo[key] = _build_node(node, by_id, visiting, via_pin, memo) + return memo[key] + + +def _build_node(node, by_id, visiting, via_pin, memo): + if node.local_id in visiting: + return Signal("" % node.local_id) + visiting = visiting | set([node.local_id]) + + if node.kind == BLOCK: + inputs = [] + for connection in node.inputs: + upstream = by_id.get(connection.ref_id) + source = None + if upstream is not None: + source = _build(upstream, by_id, visiting, connection.source_pin, memo) + if connection.negated and source is not None: + # The bubble on the pin itself, not on what feeds it. + source = _negate(source) + inputs.append((connection.target_pin, source)) + + active = via_pin + if active is None and node.outputs: + active = node.outputs[0][0] + + return Call( + type_name=node.type_name, + instance_name=node.instance_name, + inputs=inputs, + outputs=list(node.outputs), + active_output=active, + # via_pin is set by the consumer; a network sink has none. + output_wired=via_pin is not None, + st_code=list(node.st_code), + negated_outputs=set(node.negated_outputs), + ) + + if node.kind in (OUT_VARIABLE, JUMP, RETURN, CONNECTOR): + source = None + for connection in node.inputs: + upstream = by_id.get(connection.ref_id) + if upstream is not None: + source = _build(upstream, by_id, visiting, connection.source_pin, memo) + break + if node.kind == OUT_VARIABLE: + return Assign(node.label or "?", source, negated=node.negated) + if node.kind == CONNECTOR: + # A connector names the wire feeding it, so it renders as an + # assignment to that name and the matching continuation reads the + # name back. Not real ST - but the logic stays on the page. + return Assign(node.label or "?", source, negated=node.negated) + return Jump(node.label or ("RETURN" if node.kind == RETURN else "?"), source) + + if node.kind == LABEL: + return Label(node.label or "?") + + # A continuation lands here: its label is the wire's name, so it reads + # like any other signal. + return Signal(node.label or "", negated=node.negated) + + +def _component_finder(logic): + """Union-find over the wires, ignoring direction. + + Two outputs fed from one block belong to the same network, so grouping has + to follow wires backwards as well as forwards. + """ + parent = {} + for node in logic: + parent[node.local_id] = node.local_id + + def find(item): + root = item + while parent[root] != root: + root = parent[root] + while parent[item] != root: + parent[item], item = root, parent[item] + return root + + for node in logic: + for connection in node.inputs: + if connection.ref_id not in parent: + continue + left, right = find(node.local_id), find(connection.ref_id) + if left != right: + parent[left] = right + return find + + +def build_networks(nodes): + """Group a flat node list into Networks. + + One network per connected component, not one per sink. A block driving two + outVariables is a single network in the editor; splitting it produced two + networks with the whole shared expression written out twice, and threw the + numbering out against what a reviewer sees in CODESYS. + """ + logic = [node for node in nodes if node.kind != COMMENT] + + by_id = {} + for node in logic: + by_id[node.local_id] = node + + find = _component_finder(logic) + + consumed = set() + for node in logic: + for connection in node.inputs: + consumed.add(connection.ref_id) + + # A comment applies to the component whose first element follows it. + comments = {} + pending = "" + for node in nodes: + if node.kind == COMMENT: + pending = node.label or "" + continue + root = find(node.local_id) + if root not in comments: + comments[root] = pending + pending = "" + + # Shared upstream nodes must come back as the same object, so the + # renderers can tell a fan-out from two coincidentally equal expressions. + memo = {} + networks = [] + by_root = {} + for node in logic: + if node.local_id in consumed or node.kind not in SINK_KINDS: + continue + tree = _build(node, by_id, set(), None, memo) + root = find(node.local_id) + if root in by_root: + by_root[root].outputs.append(tree) + else: + network = Network(comment=comments.get(root, ""), outputs=[tree]) + by_root[root] = network + networks.append(network) + return networks + + +LANGUAGE = "FBD" + + +def pou_from_body(pou_elem, body_elem): + """Build a Pou from an already-located body. + + Split out from parse_pous so a caller handling several languages can make + a single pass over the document rather than one per language. + """ + return Pou( + name=pou_elem.get("name") or "", + pou_type=pou_elem.get("pouType") or "program", + language=LANGUAGE, + variables=parse_interface(find_child(pou_elem, "interface")), + declaration_text=declaration_text(pou_elem), + networks=build_networks(parse_fbd_body(body_elem)), + ) + + +def parse_pous(source): + """Parse every FBD POU in a PLCopen file. Other languages are skipped.""" + pous = [] + for pou_elem, language, body in iter_bodies(source): + if language == LANGUAGE: + pous.append(pou_from_body(pou_elem, body)) + return pous diff --git a/src/parse_ld.py b/src/parse_ld.py new file mode 100644 index 0000000..f184f81 --- /dev/null +++ b/src/parse_ld.py @@ -0,0 +1,323 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Parse Ladder Diagram bodies out of PLCopen XML. + +The XML-level helpers live in plcopen.py; this module owns the LD-specific +part: turning a flat list of wired elements into one series/parallel +expression tree per rung. +""" + +from model import ( + BLOCK, + IN_VARIABLE, + JUMP, + LABEL, + LEFT_RAIL, + RAILS, + RETURN, + RIGHT_RAIL, + CONTACT, + COIL, + Element, + Empty, + Node, + Parallel, + Pou, + Series, + is_simple_term, + parallel, + series, +) +from plcopen import ( + attr, + block_connections, + block_outputs, + child_text, + declaration_text, + direct_connections, + find_child, + is_true, + iter_bodies, + negated_output_pins, + parse_interface, + tag, +) + +# Elements that carry logic. Rails are structural: they anchor a rung but draw +# nothing themselves. +KNOWN_KINDS = ( + LEFT_RAIL, + RIGHT_RAIL, + CONTACT, + COIL, + BLOCK, + "inVariable", + "outVariable", + JUMP, + RETURN, + LABEL, +) + + +def _node_label(elem, kind): + if kind == BLOCK: + # typeName and instanceName are attributes in CODESYS's output, not + # the child elements a literal schema reading would suggest. + return elem.get("instanceName") or elem.get("typeName") + if kind in (JUMP, LABEL): + # The target is a "label" attribute, not a child element - same as in + # FBD bodies. Reading child elements here loses the target entirely. + return elem.get("label") + return child_text(elem, "variable") or child_text(elem, "expression") + + +def parse_ld_body(body_elem): + """Return an ordered list of Nodes from an body element.""" + nodes = [] + for child in body_elem: + kind = tag(child) + if kind not in KNOWN_KINDS: + continue + local_id = child.get("localId") + if local_id is None: + continue + is_block = kind == BLOCK + nodes.append( + Node( + local_id=local_id, + kind=kind, + label=_node_label(child, kind), + negated=is_true(child, "negated"), + edge=attr(child, "edge"), + storage=attr(child, "storage"), + inputs=block_connections(child) if is_block else direct_connections(child), + type_name=child.get("typeName") if is_block else None, + instance_name=child.get("instanceName") if is_block else None, + outputs=block_outputs(child) if is_block else None, + negated_outputs=negated_output_pins(child) if is_block else None, + ) + ) + return nodes + + +# --- graph to expression tree ---------------------------------------------- + + +def _to_element(node): + return Element( + kind=node.kind, + label=node.label, + negated=node.negated, + edge=node.edge, + storage=node.storage, + ) + + +def expr_to_text(expr): + """Flatten an expression to one line of ST-ish text. + + Used for a block's side inputs: a RESET pin fed by its own contact chain + cannot be drawn as a second horizontal wire without a genuine 2-D layout, + so it is written into the pin as "RESET := PowerOff" instead. + """ + if isinstance(expr, Empty): + return "" + if isinstance(expr, Series): + parts = [part for part in (expr_to_text(item) for item in expr.items) if part] + return " AND ".join(parts) + if isinstance(expr, Parallel): + parts = [part for part in (expr_to_text(branch) for branch in expr.branches) if part] + return "(" + " OR ".join(parts) + ")" + if isinstance(expr, Element): + if expr.kind == BLOCK: + base = expr.instance_name or expr.type_name or "?" + text = (base + "." + expr.active_output) if expr.active_output else base + # The negation bubble on the consumed output inverts what leaves + # the box - on this flattened path just like on the power flow. + if expr.active_output in expr.negated_outputs: + return "NOT " + text + return text + label = expr.label or "" + if expr.edge == "rising": + return "R(" + label + ")" + if expr.edge == "falling": + return "F(" + label + ")" + if expr.negated: + return "NOT " + _bracket(label) + return label + return "?" + + +def _bracket(text): + """Parenthesise a compound term before negating or nesting it. + + NOT binds above OR, AND and even comparison in IEC 61131-3, so both + "NOT xA OR xB" and the spaceless "NOT iCount>5" regroup the logic their + bracketed forms state. + """ + if is_simple_term(text): + return text + return "(" + text + ")" + + +def _build_block(node, by_id, visiting, via_pin): + """Build a block call, separating power flow from parameter inputs. + + Exactly one input carries the rung's power flow. Pins fed by a literal or + an inVariable are parameters, not power, so the first genuinely wired pin + wins and the rest become captions inside the box. + """ + power_expr = Empty() + power_pin = None + power_negated = False + side_pins = [] + + for connection in node.inputs: + upstream = by_id.get(connection.ref_id) + if upstream is None: + side_pins.append((connection.target_pin, "?")) + continue + sub_expr = _build_expr(upstream, by_id, visiting, connection.source_pin) + if upstream.kind == IN_VARIABLE: + # Flattened through expr_to_text, not taken from the raw label: + # an in-place negated inVariable must keep its NOT, or the pin + # silently inverts. + side_pins.append((connection.target_pin, _pin_text(sub_expr, connection))) + elif power_pin is None: + power_pin = connection.target_pin + power_expr = sub_expr + # The pin's own negation bubble; it inverts the power flow at the + # box wall, after everything the rung has accumulated. + power_negated = connection.negated + else: + side_pins.append((connection.target_pin, _pin_text(sub_expr, connection))) + + input_pins = [] + if power_pin is not None: + # None marks the power pin, and it sorts first so the wire runs + # straight through the box instead of jogging to another row. + input_pins.append((power_pin, None)) + input_pins.extend(side_pins) + + active = via_pin + if active is None and node.outputs: + active = node.outputs[0][0] + output_pins = [out for out in node.outputs if out[0] == active] + output_pins += [out for out in node.outputs if out[0] != active] + + element = Element( + kind=BLOCK, + label=node.label, + type_name=node.type_name, + instance_name=node.instance_name, + input_pins=input_pins, + output_pins=output_pins, + active_output=active, + # via_pin is set by whatever consumed this block; a block terminating + # the rung has none. + output_wired=via_pin is not None, + power_negated=power_negated, + negated_outputs=set(node.negated_outputs), + ) + return series([power_expr, element]) + + +def _pin_text(sub_expr, connection): + """A side pin's caption, honouring the pin's own negation bubble.""" + text = expr_to_text(sub_expr) + if connection.negated: + return "NOT " + _bracket(text) if text else "NOT ?" + return text + + +def _build_expr(node, by_id, visiting, via_pin=None): + """Walk backwards from a node to the power rail, building series/parallel. + + A node's expression is everything feeding it (OR'd together if there is + more than one input) followed by the node itself. + """ + if node.local_id in visiting: + # Feedback loops are not legal in a rung, but a malformed export should + # produce a visible marker rather than blow the stack. + return Element(kind="cycle", label="" % node.local_id) + + visiting = visiting | set([node.local_id]) + + if node.kind == BLOCK: + return _build_block(node, by_id, visiting, via_pin) + + branches = [] + for connection in node.inputs: + upstream = by_id.get(connection.ref_id) + if upstream is None: + continue + branches.append(_build_expr(upstream, by_id, visiting, connection.source_pin)) + + incoming = parallel(branches) if branches else Empty() + + if node.kind in RAILS: + # Rails are anchors, not symbols - they contribute nothing to draw. + return incoming + + return series([incoming, _to_element(node)]) + + +def build_rungs(nodes): + """Split a flat node list into one expression tree per rung. + + A rung is identified by its terminal: an element nothing else consumes. + That is the right power rail where one exists, and the coil itself where + the export omits it - CODESYS exports the right rail unconnected. + """ + by_id = {} + for node in nodes: + by_id[node.local_id] = node + + consumed = set() + for node in nodes: + for connection in node.inputs: + consumed.add(connection.ref_id) + + rungs = [] + for node in nodes: + if node.local_id in consumed: + continue + if node.kind == LEFT_RAIL: + # An unconnected left rail is an empty rung, not a terminal. + continue + expr = _build_expr(node, by_id, set()) + if isinstance(expr, Empty): + continue + rungs.append(expr) + return rungs + + +LANGUAGE = "LD" + + +def pou_from_body(pou_elem, body_elem): + """Build a Pou from an already-located body. + + Split out from parse_pous so a caller handling several languages can make + a single pass over the document instead of re-reading and re-parsing it + once per language. + """ + return Pou( + name=pou_elem.get("name") or "", + pou_type=pou_elem.get("pouType") or "program", + language=LANGUAGE, + variables=parse_interface(find_child(pou_elem, "interface")), + declaration_text=declaration_text(pou_elem), + rungs=build_rungs(parse_ld_body(body_elem)), + ) + + +def parse_pous(source): + """Parse every LD POU in a PLCopen file. Other languages are skipped. + + ``source`` is a path or a file object, as accepted by ElementTree. + """ + pous = [] + for pou_elem, language, body in iter_bodies(source): + if language == LANGUAGE: + pous.append(pou_from_body(pou_elem, body)) + return pous diff --git a/src/plcopen.py b/src/plcopen.py new file mode 100644 index 0000000..764aea4 --- /dev/null +++ b/src/plcopen.py @@ -0,0 +1,435 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Low-level PLCopen XML helpers shared by every language renderer. + +Namespaces are stripped rather than matched. The exact URI varies between +schema revisions - the hand-authored fixture is tc6_0201, real CODESYS output +is tc6_0200 - and CODESYS layers proprietary extensions on top. Matching local +tag names survives all of it. +""" + +import io +import warnings + +# CODESYS puts its own ScriptLib ahead of the standard library, and its xml +# package imports the deprecated xmllib on the way in. That prints a +# DeprecationWarning plus the offending source line into the message view, +# where CODESYS red-flags both as errors. Nobody can act on it - it is +# CODESYS's own bundled library - so it is silenced at the point it fires. +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import xmlbackend + +from model import Connection + +TRUTHY = ("true", "1") + +# Body element names, one of which wraps every POU implementation. +BODY_LANGUAGES = ("LD", "FBD", "SFC", "ST", "IL", "CFC") + + +def tag(elem): + """Local tag name, with any namespace stripped.""" + return elem.tag.split("}")[-1] + + +def find_child(elem, name): + for child in elem: + if tag(child) == name: + return child + return None + + +def child_text(elem, name): + child = find_child(elem, name) + if child is None or child.text is None: + return None + return child.text.strip() + + +def is_true(elem, attr_name): + return (elem.get(attr_name) or "").lower() in TRUTHY + + +def attr(elem, name): + """An attribute, treating CODESYS's literal "none" as absent.""" + value = elem.get(name) + if value in (None, "", "none"): + return None + return value + + +def direct_connections(elem): + """Wires arriving at this element's own connectionPointIn children. + + Several under a single connectionPointIn is how PLCopen + spells a parallel branch (a wired OR), so order and multiplicity matter. + This deliberately does not recurse: a block's pins hang off + and are collected separately, with their pin names. + """ + connections = [] + for point in elem: + if tag(point) != "connectionPointIn": + continue + for child in point: + if tag(child) != "connection": + continue + ref = child.get("refLocalId") + if ref is not None: + connections.append(Connection(ref, source_pin=attr(child, "formalParameter"))) + return connections + + +def block_connections(block_elem): + """Wires arriving at a block, tagged with the pin they land on. + + A pin variable can carry negated="true" - the bubble CODESYS draws on the + pin itself. It applies to everything arriving at that pin, so it rides on + each connection. + """ + connections = [] + for group_name in ("inputVariables", "inOutVariables"): + group = find_child(block_elem, group_name) + if group is None: + continue + for var in group: + if tag(var) != "variable": + continue + pin = var.get("formalParameter") + pin_negated = is_true(var, "negated") + for connection in direct_connections(var): + connection.target_pin = pin + connection.negated = pin_negated + connections.append(connection) + return connections + + +def block_outputs(block_elem): + """(pin, assigned variable) for each block output. + + CODESYS writes an assignment straight onto the output pin as + uiCurrSupplyVolt. + """ + outputs = [] + group = find_child(block_elem, "outputVariables") + if group is None: + return outputs + for var in group: + if tag(var) != "variable": + continue + assigned = None + point = find_child(var, "connectionPointOut") + if point is not None: + expression = find_child(point, "expression") + if expression is not None and expression.text: + assigned = expression.text.strip() + outputs.append((var.get("formalParameter"), assigned)) + return outputs + + +def negated_output_pins(block_elem): + """Output pins carrying an in-place negation bubble (negated="true"). + + The value leaving such a pin is the inverse of the pin, so an inline + assignment stores NOT pin and a consumer reads NOT pin. Dropping the flag + renders the exact opposite of the program. + """ + pins = set() + group = find_child(block_elem, "outputVariables") + if group is None: + return pins + for var in group: + if tag(var) != "variable": + continue + if is_true(var, "negated"): + pins.add(var.get("formalParameter")) + return pins + + +def block_st_code(block_elem): + """Inline ST carried by an EXECUTE box, as a list of lines. + + CODESYS puts the whole body of an EXECUTE box in an addData STCode + element. It is the only content the box has, so ignoring it draws an empty + box where a dozen lines of logic should be. + """ + add_data = find_child(block_elem, "addData") + if add_data is None: + return [] + for data in add_data: + if tag(data) != "data": + continue + code = find_child(data, "STCode") + if code is not None and code.text: + return code.text.replace("\r\n", "\n").strip("\n").split("\n") + return [] + + +def comment_text(elem): + """The text of a , which nests its content in an xhtml element.""" + content = find_child(elem, "content") + if content is None: + return "" + xhtml = find_child(content, "xhtml") + if xhtml is None or xhtml.text is None: + return "" + return xhtml.text.strip() + + +# --- interface ------------------------------------------------------------- + +SCOPE_TAGS = { + "localVars": "VAR", + "inputVars": "VAR_INPUT", + "outputVars": "VAR_OUTPUT", + "inOutVars": "VAR_IN_OUT", + "tempVars": "VAR_TEMP", + "globalVars": "VAR_GLOBAL", +} + + +def _type_name(var_elem): + type_elem = find_child(var_elem, "type") + if type_elem is None: + return "BOOL" + for child in type_elem: + name = tag(child) + if name == "derived": + return child.get("name") or "UNKNOWN" + return name + return "BOOL" + + +def _initial_value(var_elem): + value_elem = find_child(var_elem, "initialValue") + if value_elem is None: + return None + simple = find_child(value_elem, "simpleValue") + if simple is None: + return None + return simple.get("value") + + +def _add_data_declaration(owner): + """A declaration blob anywhere in this element's own addData, or None. + + The whole addData subtree is walked rather than its first two levels. + CODESYS writes the text under a data element named + ".../plcopenxml/interfaceasplaintext", and how deeply it nests inside that + is exactly the kind of detail that differs between versions. + """ + if owner is None: + return None + add_data = find_child(owner, "addData") + if add_data is None: + return None + candidates = [] + for element in add_data.iter(): + text = element.text + if text and "VAR" in text and "END_VAR" in text: + candidates.append((element, text.replace("\r\n", "\n").strip("\n"))) + if not candidates: + return None + + named = [] + for element, text in candidates: + name = (element.get("name") or "").lower() + if "interfaceasplaintext" in name or "declaration" in name: + named.append(text) + if len(named) == 1: + return named[0] + if len(candidates) == 1: + return candidates[0][1] + return None + + +def declaration_text(pou_elem): + """The lossless plaintext declaration, if CODESYS wrote one. + + Requested with export_xml(declarations_as_plaintext=True). The structured + has nowhere to put a comment, a pragma or an attribute, so + rebuilding a declaration from it silently drops all three - and a pragma + like {attribute 'qualified_only'} changes what the code means. + + Both the interface's addData and the POU's own are searched, because the + flag demonstrably writes the text - it grows the export by well over a + kilobyte - but not inside , which was the only place the first + attempt looked. + + Neither the element name nor the data name is matched exactly: this is a + proprietary 3S extension whose naming has moved between CODESYS versions, + and pinning a name that later changed would drop silently back to the + lossy path. Requiring both VAR and END_VAR keeps that loose match from + catching arbitrary prose. + """ + if pou_elem is None: + return None + return _add_data_declaration(find_child(pou_elem, "interface")) or _add_data_declaration(pou_elem) + + +def parse_interface(interface_elem): + """Variables from a POU interface, in declaration order.""" + from model import Variable + + variables = [] + if interface_elem is None: + return variables + for group in interface_elem: + scope = SCOPE_TAGS.get(tag(group)) + if scope is None: + continue + if is_true(group, "constant"): + scope += " CONSTANT" + for var_elem in group: + if tag(var_elem) != "variable": + continue + variables.append( + Variable( + name=var_elem.get("name") or "", + type_name=_type_name(var_elem), + initial_value=_initial_value(var_elem), + scope=scope, + ) + ) + return variables + + +def read_document(source): + """Document bytes, with anything before the first tag removed. + + CODESYS writes a UTF-8 BOM on every export_xml file, and the ElementTree + that CODESYS ships in ScriptLib rejects it outright: + + Error('Syntax error at line 1: illegal data at start of file',) + + CPython's expat accepts a BOM silently, so this is invisible outside + CODESYS. Slicing to the first "<" handles the BOM however it is + represented, plus any stray leading whitespace, in one step - nothing + before the first tag can be XML anyway. + + ``source`` is a path or a file object. io.open is used rather than the + builtin so a binary read returns real bytes under IronPython too. + """ + if hasattr(source, "read"): + data = source.read() + else: + handle = io.open(source, "rb") + try: + data = handle.read() + finally: + handle.close() + + if not isinstance(data, bytes): + data = data.encode("utf-8") + + start = data.find(b"<") + if start > 0: + data = data[start:] + return _to_ascii(data) + + +def _to_ascii(data): + """Replace non-ASCII characters with XML numeric character references. + + The ElementTree CODESYS ships works byte-wise and rejects UTF-8 multi-byte + sequences outright: + + Error('Syntax error at line 216: illegal character in content',) + + A numeric reference is plain ASCII, and every parser expands it back to + the same character, so the parsed result is identical while the bytes + handed to the parser are safe. One degree sign in a comment is enough to + lose a whole POU otherwise. + + Safe as a blanket transform because PLCopen exports contain no CDATA + sections, which are the one place a numeric reference would stay literal + text instead of being expanded. + """ + try: + # Native-speed check, and the overwhelmingly common case. Scanning + # byte by byte in Python costs real time on a large project. + data.decode("ascii") + return data + except UnicodeDecodeError: + pass + + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + # Not valid UTF-8 despite the declaration. latin-1 cannot fail, and + # preserves every byte as a character so nothing is lost. + text = data.decode("latin-1") + + try: + # This error handler does exactly the job, natively. + return text.encode("ascii", "xmlcharrefreplace") + except (LookupError, ValueError): + pieces = [] + for character in text: + pieces.append(character if ord(character) < 128 else "&#%d;" % ord(character)) + return "".join(pieces).encode("ascii") + + +# XML 1.0 forbids these outright - they cannot even be written as a numeric +# reference, so a document containing one is malformed at the source. +_LEGAL_CONTROL = (0x09, 0x0A, 0x0D) + + +def describe_suspect_characters(source, limit=5): + """Characters likely to make a parser reject the document. + + Reported on a rendering failure so the next run explains itself, rather + than needing another round of manual diagnosis. + """ + try: + handle = io.open(source, "rb") + try: + raw = handle.read() + finally: + handle.close() + except (IOError, OSError) as error: + return ["could not re-read the file: " + repr(error)] + + notes = [] + for line_number, line in enumerate(raw.split(b"\n"), 1): + for column, byte in enumerate(bytearray(line), 1): + if byte < 0x20 and byte not in _LEGAL_CONTROL: + notes.append( + "line %d column %d: control character 0x%02X, illegal in XML 1.0" % (line_number, column, byte) + ) + elif byte > 0x7F: + notes.append("line %d column %d: non-ASCII byte 0x%02X" % (line_number, column, byte)) + if len(notes) >= limit: + return notes + return notes + + +def find_pous(root): + """POU elements, without walking the whole document to find them. + + PLCopen puts them at project/types/pous/pou. Scanning every element + instead meant touching a few thousand nodes per file to reach one or two, + which is pure waste under any backend and expensive under one whose + elements are wrapped in Python objects. The full walk stays as a fallback + for any layout that does not match. + """ + types = find_child(root, "types") + if types is not None: + pous = find_child(types, "pous") + if pous is not None: + found = [child for child in pous if tag(child) == "pou"] + if found: + return found + return [elem for elem in root.iter() if tag(elem) == "pou"] + + +def iter_bodies(source): + """Yield (pou_elem, language, body_elem) for every POU with an implementation.""" + root = xmlbackend.parse(read_document(source)) + for elem in find_pous(root): + body = find_child(elem, "body") + if body is None: + continue + for child in body: + if tag(child) in BODY_LANGUAGES: + yield elem, tag(child), child + break diff --git a/src/script_diagnose_xml.py b/src/script_diagnose_xml.py new file mode 100644 index 0000000..c00c774 --- /dev/null +++ b/src/script_diagnose_xml.py @@ -0,0 +1,248 @@ +# REMEMBER: this is python 2.7 +"""Diagnose why PLCopen rendering fails inside CODESYS. + +Run this the same way as the other scripts (Tools > Scripting > Execute Script +File, or add it as a toolbar command) with the affected project open. It writes +everything to the message view and changes nothing. + +It answers three questions: + + 1. Which xml module is actually being imported? CODESYS ships its own XML + modules in ScriptLib, which is on sys.path and can shadow the standard + library. + 2. Can that module parse a trivial document, with and without a UTF-8 BOM? + CODESYS writes a BOM, and older parsers reject it as "illegal data at + start of file". + 3. What do the first bytes of a real export_xml file actually look like? +""" + +from __future__ import print_function + +import os +import sys +import tempfile + +import scriptengine # type: ignore + +from object_type import ObjectType, get_object_type +from util import print_python_version + +PLAIN = b'hi' +WITH_BOM = b"\xef\xbb\xbf" + PLAIN + + +def report(label, value): + print(" " + label.ljust(28) + str(value)) + + +def probe_parser(): + print("--- xml module ---") + try: + import xml + + report("xml.__file__", getattr(xml, "__file__", "")) + report("xml.__path__", getattr(xml, "__path__", "")) + except Exception as error: + report("import xml FAILED", repr(error)) + + try: + import xml.etree.ElementTree as ET + + report("ElementTree.__file__", getattr(ET, "__file__", "")) + report("ElementTree.VERSION", getattr(ET, "VERSION", "")) + except Exception as error: + report("import ElementTree FAILED", repr(error)) + return None + + for label, data in (("without BOM", PLAIN), ("with BOM", WITH_BOM)): + try: + root = ET.fromstring(data) + report("fromstring " + label, "OK, root tag " + repr(root.tag)) + except Exception as error: + report("fromstring " + label, "FAILED " + repr(error)) + + # parse() takes a different path to fromstring() in some implementations, + # and parse() is what the renderer actually uses. + for label, data in (("without BOM", PLAIN), ("with BOM", WITH_BOM)): + handle, path = tempfile.mkstemp(suffix=".xml") + try: + os.write(handle, data) + os.close(handle) + tree = ET.parse(path) + report("parse " + label, "OK, root tag " + repr(tree.getroot().tag)) + except Exception as error: + report("parse " + label, "FAILED " + repr(error)) + finally: + if os.path.exists(path): + os.remove(path) + + return ET + + +def find_graphical_object(obj, depth=0): + """First graphical POU in the project. + + has_textual_implementation is False on plenty of objects that are not + POUs, and the first one found is usually Project Information - whose + export has no at all, so probing it says nothing about + declarations while looking like it did. + """ + if depth > 12: + return None + try: + children = obj.get_children() + except Exception: + return None + for child in children: + try: + if get_object_type(child) == ObjectType.POU and child.has_textual_implementation is False: + return child + except Exception: + pass + found = find_graphical_object(child, depth + 1) + if found is not None: + return found + return None + + +def probe_export(ET): + print("--- a real export_xml file ---") + project = scriptengine.projects.primary + if project is None: + report("project", "none open - open the affected project and re-run") + return + + target = find_graphical_object(project) + if target is None: + report("graphical object", "none found") + return + + report("object", target.get_name()) + + handle, path = tempfile.mkstemp(suffix=".plcopen.xml") + os.close(handle) + try: + target.export_xml(path=path, recursive=False) + size = os.path.getsize(path) + report("bytes written", size) + if size == 0: + report("verdict", "export_xml wrote an EMPTY file") + return + + f = open(path, "rb") + try: + head = f.read(160) + finally: + f.close() + report("first bytes", repr(head)) + report("starts with BOM", head[:3] == b"\xef\xbb\xbf") + + if ET is not None: + try: + ET.parse(path) + report("parse of real file", "OK") + except Exception as error: + report("parse of real file", "FAILED " + repr(error)) + except Exception as error: + report("export_xml FAILED", repr(error)) + finally: + if os.path.exists(path): + os.remove(path) + + +def probe_declarations(): + """Which export_xml call binds, and where the declaration text lands. + + export_xml is a .NET overload set. IronPython resolves it by signature, so + a keyword call can fail to bind where the same call positionally succeeds + - and the failure is a TypeError that looks exactly like "this build has + no such overload". Only trying each shape distinguishes them. + """ + print("--- plaintext declarations ---") + project = scriptengine.projects.primary + if project is None: + report("project", "none open") + return + + target = find_graphical_object(project) + if target is None: + report("graphical POU", "none found - open a project with an LD or FBD POU") + return + report("graphical POU", target.get_name()) + + shapes = ( + ("positional (path, rec, folders, plaintext)", lambda o, p: o.export_xml(p, False, False, True)), + ("keyword", lambda o, p: o.export_xml(path=p, recursive=False, declarations_as_plaintext=True)), + ("reporter-first", lambda o, p: o.export_xml(None, p, False, False, True)), + ("plain (no plaintext)", lambda o, p: o.export_xml(p, False)), + ) + + exports = {} + for label, call in shapes: + handle, path = tempfile.mkstemp(suffix=".plcopen.xml") + os.close(handle) + try: + call(target, path) + f = open(path, "rb") + try: + exports[label] = f.read() + finally: + f.close() + report(label, "OK, %d bytes" % len(exports[label])) + except TypeError as error: + report(label, "no such overload (%s)" % error) + except Exception as error: + report(label, "FAILED " + repr(error)) + finally: + if os.path.exists(path): + os.remove(path) + + # The flag grows the export, so the text is being written somewhere. Name + # every addData in each version and report what the flag adds - guessing + # at where it lands has already cost two round trips. + plain = exports.get("plain (no plaintext)") + with_text = exports.get("positional (path, rec, folders, plaintext)") or exports.get("keyword") + if plain is None or with_text is None: + return + + report("size difference", "%d bytes added by the flag" % (len(with_text) - len(plain))) + + def data_names(content): + names = [] + index = content.find(b'", + "GE": ">=", + "LT": "<", + "LE": "<=", + "EQ": "=", + "NE": "<>", +} + + +def _operand(text): + """Parenthesise anything that is not a single term. + + Redundant brackets are preferable to an expression that reads correctly + but groups wrongly - and "iCount>5" is as compound as "xA OR xB", see + is_simple_term. + """ + return text if is_simple_term(text) else "(" + text + ")" + + +def _operator_expression(node, values): + symbol = INFIX_OPERATORS.get(node.type_name) + if symbol and len(values) >= 2: + return (" " + symbol + " ").join(_operand(value) for value in values) + if node.type_name == "NOT" and len(values) == 1: + return "NOT " + _operand(values[0]) + return "%s(%s)" % (node.type_name or "?", ", ".join(values)) + + +def _fbd_value(node, statements, emitted=None): + """Value of a node as ST text, appending any statements it needs first. + + ``emitted`` maps an already-rendered node to its value, so a block + feeding two outputs is called once rather than once per output. + """ + if emitted is None: + emitted = {} + if node is None: + return "" + + if isinstance(node, Signal): + return node.text + + if isinstance(node, Label): + statements.append("(* label: %s *)" % node.name) + return "" + + if isinstance(node, Jump): + condition = _fbd_value(node.condition, statements, emitted) + if condition: + statements.append("IF %s THEN (* JMP %s *) END_IF" % (condition, node.target)) + else: + statements.append("(* JMP %s *)" % node.target) + return "" + + if isinstance(node, Assign): + value = _fbd_value(node.source, statements, emitted) or "FALSE" + if node.negated: + value = "NOT " + _operand(value) + statements.append("%s := %s;" % (node.label or "?", value)) + return node.label or "?" + + if isinstance(node, Call): + if id(node) in emitted: + return emitted[id(node)] + pairs = [] + for pin, source in node.inputs: + value = _fbd_value(source, statements, emitted) + if value: + pairs.append((pin, value)) + + def remember(value): + emitted[id(node)] = value + return value + + if node.st_code: + # An EXECUTE box is inline ST already, so emit it as itself rather + # than as a call to a box that has no body. + guard = dict(pairs).get("EN") + if guard and guard != "TRUE": + statements.append("IF %s THEN" % guard) + statements.extend(" " + line for line in node.st_code) + statements.append("END_IF") + else: + statements.extend(node.st_code) + return remember("") + + if node.is_operator: + # Operators and functions have no instance to call, so they inline + # as an expression rather than a statement. + expression = _operator_expression(node, [value for _pin, value in pairs]) + if node.active_output in node.negated_outputs: + expression = "NOT " + _operand(expression) + return remember(expression) + + name = node.instance_name + statements.append("%s(%s);" % (name, ", ".join("%s := %s" % (pin, value) for pin, value in pairs))) + for pin, assigned in node.outputs: + if assigned: + value = "%s.%s" % (name, pin) + # A negated output pin stores its inverse. + if pin in node.negated_outputs: + value = "NOT " + value + statements.append("%s := %s;" % (assigned, value)) + result = (name + "." + node.active_output) if node.active_output else name + if node.active_output in node.negated_outputs: + result = "NOT " + result + return remember(result) + + return "?" + + +def network_to_statements(network): + """Statements for one network, which may drive several outputs. + + The shared logic is emitted once: a function block feeding two outputs is + called once in the program, so calling it twice here would misrepresent + it. Plain expressions still repeat, which is what ST would say anyway. + """ + statements = [] + emitted = {} + for tree in getattr(network, "outputs", [network]): + before = len(statements) + value = _fbd_value(tree, statements, emitted) + if len(statements) == before and value: + # A bare expression with nothing to assign it to - keep it visible + # rather than dropping it entirely. + statements.append("(* " + value + " *)") + return statements + + +def _network_header(index, comment): + header = "(* Network " + str(index + 1) + if comment: + comment = comment.replace("\r", " ").replace("\n", " ").replace("*)", "* )") + header += ": " + comment.lstrip("/").strip() + return header + " *)" + + +def render_pou(pou): + """Render a POU as declaration plus ST statements, one block per network.""" + lines = render_declaration(pou) + lines.append("") + + for index, rung in enumerate(pou.rungs): + lines.append(_network_header(index, "")) + lines.extend(rung_to_statements(rung)) + lines.append("") + + for index, network in enumerate(pou.networks): + lines.append(_network_header(index, network.comment)) + lines.extend(network_to_statements(network)) + lines.append("") + + if not pou.rungs and not pou.networks: + lines.append("(* no networks *)") + + while lines and lines[-1] == "": + lines.pop() + + return [line.rstrip() for line in lines] diff --git a/src/xmlbackend.py b/src/xmlbackend.py new file mode 100644 index 0000000..be8c157 --- /dev/null +++ b/src/xmlbackend.py @@ -0,0 +1,176 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Parse XML with whatever the host can do fastest. + +CODESYS puts its own ScriptLib ahead of the standard library, and the +ElementTree it ships there is the xmllib-era one that parses in pure Python. +Measured on a real project: 25 POUs took 6.8s, of which 6.4s was parsing and +0.3s was drawing the diagrams. The layout code was never the problem. + +IronPython runs on .NET, so System.Xml is right there and native. This picks +it when it is available and falls back to ElementTree otherwise, which is +what CPython uses when running the tests. + +The two backends must agree exactly, because the golden files are generated +under CPython and consumed by CODESYS. test_xmlbackend.py compares them +element for element wherever both are available - which is the IronPython CI +job, the only place that can. + +Only the small slice of the ElementTree API this project actually uses is +implemented: a tag, attributes, leading text, iteration over child elements, +and a recursive walk. +""" + +import xml.etree.ElementTree as ET + +ELEMENT_TREE = "ElementTree" +SYSTEM_XML = "System.Xml" + +try: + import clr + + clr.AddReference("System.Xml") + from System import Array, Byte + from System.IO import MemoryStream + from System.Xml import XmlDocument, XmlNodeType + + _SYSTEM_XML_AVAILABLE = True +except Exception: # pragma: no cover - only reachable off IronPython + _SYSTEM_XML_AVAILABLE = False + + +class _DotNetElement(object): + """The slice of the ElementTree element API this project uses.""" + + __slots__ = ("_node", "_children") + + def __init__(self, node): + self._node = node + self._children = None + + @property + def tag(self): + # LocalName drops the namespace, which is what plcopen.tag() would + # have stripped anyway. + return self._node.LocalName + + def get(self, name, default=None): + attributes = self._node.Attributes + if attributes is None: + return default + found = attributes.GetNamedItem(name) + # An absent attribute must be None rather than "": callers use + # "is None" to tell "not written" from "written empty". + return found.Value if found is not None else default + + @property + def text(self): + """Text before the first child element, as ElementTree defines it. + + Not InnerText, which would flatten descendants and make the two + backends disagree on mixed content. + """ + parts = [] + for child in self._node.ChildNodes: + node_type = child.NodeType + if node_type == XmlNodeType.Element: + break + if node_type in ( + XmlNodeType.Text, + XmlNodeType.CDATA, + XmlNodeType.Whitespace, + XmlNodeType.SignificantWhitespace, + ): + parts.append(child.Value) + if not parts: + return None + text = "".join(parts) + # XML requires a parser to normalise line endings to \n, and + # ElementTree does. XmlDocument does too - except for the whitespace + # nodes PreserveWhitespace keeps, which come back with CR intact. Real + # CODESYS exports are CRLF throughout, so this is not a corner case. + if "\r" in text: + text = text.replace("\r\n", "\n").replace("\r", "\n") + return text + + def __iter__(self): + # Wrapped once and kept. The parsers call find_child several times on + # the same element - a block asks for inputVariables, inOutVariables + # and outputVariables in turn - and re-wrapping every child on each + # call was most of what this backend spent its time doing. + if self._children is None: + self._children = [ + _DotNetElement(child) for child in self._node.ChildNodes if child.NodeType == XmlNodeType.Element + ] + return iter(self._children) + + def iter(self): + """Pre-order walk, as ElementTree does it. + + An explicit stack rather than recursive generators: delegating a yield + up through every level of a deep document costs more than the walk. + """ + stack = [self] + while stack: + node = stack.pop() + yield node + children = list(node) + for index in range(len(children) - 1, -1, -1): + stack.append(children[index]) + + +def _parse_dotnet(data): + document = XmlDocument() + # XmlDocument drops insignificant whitespace by default, so an element + # whose only content is a newline and some indentation would report no + # text at all where ElementTree reports "\n ". Harmless for every + # current caller, since they all strip - but the backends have to agree + # about what the document says, not merely about what today's callers + # make of it. + document.PreserveWhitespace = True + # Never fetch an external DTD: a POU export should not be able to make + # CODESYS reach out to the network while someone clicks Export. + document.XmlResolver = None + stream = MemoryStream(Array[Byte](bytearray(data))) + try: + document.Load(stream) + finally: + stream.Close() + return _DotNetElement(document.DocumentElement) + + +def _parse_element_tree(data): + return ET.fromstring(data) + + +def available(): + """Backend names this host can use, fastest first.""" + names = [] + if _SYSTEM_XML_AVAILABLE: + names.append(SYSTEM_XML) + names.append(ELEMENT_TREE) + return names + + +_PARSERS = {SYSTEM_XML: _parse_dotnet, ELEMENT_TREE: _parse_element_tree} + +_active = available()[0] + + +def use(name): + """Force a backend. Returns the previous one, so tests can restore it.""" + global _active + if name not in _PARSERS: + raise ValueError("unknown xml backend %r" % (name,)) + if name == SYSTEM_XML and not _SYSTEM_XML_AVAILABLE: + raise ValueError("System.Xml is not available on this host") + previous, _active = _active, name + return previous + + +def active(): + return _active + + +def parse(data, backend=None): + """Parse document bytes and return the root element.""" + return _PARSERS[backend or _active](data) diff --git a/tools/ci/compile_python3.py b/tools/ci/compile_python3.py new file mode 100644 index 0000000..b7b3ef2 --- /dev/null +++ b/tools/ci/compile_python3.py @@ -0,0 +1,21 @@ +"""Compile src/*.py with the host Python 3 interpreter.""" +import os +import sys + +SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "src") + +failures = [] +for name in sorted(os.listdir(SRC)): + if not name.endswith(".py"): + continue + path = os.path.join(SRC, name) + try: + with open(path, "r", encoding="utf-8") as handle: + source = handle.read() + compile(source, path, "exec") + print("OK " + name) + except (OSError, SyntaxError) as error: + failures.append(name) + print("FAIL %s: %s" % (name, error)) + +sys.exit(1 if failures else 0) diff --git a/tools/ci/import_smoke.py b/tools/ci/import_smoke.py index 861c50a..2963ce1 100644 --- a/tools/ci/import_smoke.py +++ b/tools/ci/import_smoke.py @@ -26,6 +26,19 @@ "device_tree_import_export", "import_from_files", "project_template", + # Renderers for graphical POUs. No scriptengine dependency of their own, + # but they have to load under IronPython 2.7 like everything else here. + "charset", + "xmlbackend", + "layout", + "model", + "plcopen", + "parse_ld", + "parse_fbd", + "ld_render", + "fbd_render", + "st_render", + "graphical_export", ] failures = [] diff --git a/tools/ladder/render.py b/tools/ladder/render.py new file mode 100644 index 0000000..ad82d55 --- /dev/null +++ b/tools/ladder/render.py @@ -0,0 +1,112 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Render the graphical POUs in a PLCopen XML file. + + python tools/ladder/render.py [options] [...] + + --format art|st|both art diagrams, as the export writes them (default) + st equivalent Structured Text + both the ST followed by the diagram + + --charset unicode|ascii box-drawing characters (the default), or plain + ASCII for terminals and diff viewers that mangle + them + +Output is written as UTF-8 regardless of the console encoding. + +Ladder and Function Block Diagram are supported; SFC bodies are skipped. +""" + +from __future__ import print_function, unicode_literals + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +# The renderers live in src/, alongside the CODESYS export scripts. +sys.path.insert(0, os.path.join(HERE, "..", "..", "src")) + +import charset # noqa: E402 +import fbd_render # noqa: E402 +import ld_render # noqa: E402 +import parse_ld # noqa: E402 +import parse_fbd # noqa: E402 +import st_render # noqa: E402 + +FORMATS = ("art", "st", "both") + + +def _pous(path): + """Every graphical POU in the file, paired with its art renderer.""" + found = [] + for pou in parse_ld.parse_pous(path): + found.append((pou, ld_render)) + for pou in parse_fbd.parse_pous(path): + found.append((pou, fbd_render)) + return found + + +def render_file(path, output_format="art"): + lines = [] + for pou, art_renderer in _pous(path): + if output_format in ("st", "both"): + lines.extend(st_render.render_pou(pou)) + lines.append("") + if output_format in ("art", "both"): + rendered = art_renderer.render_pou(pou) + if output_format == "both": + # The diagram repeats the declaration, which is noise the + # second time around. + rendered = rendered[len(ld_render.render_declaration(pou)) :] + lines.extend(rendered) + lines.append("") + return [line.rstrip() for line in lines] + + +def write(lines, stream=None): + """Write as UTF-8 bytes. + + A Windows console defaults to a codepage that cannot encode box drawing, + so going through print() would raise UnicodeEncodeError on exactly the + output this tool exists to produce. + """ + if stream is None: + stream = sys.stdout + buffer = getattr(stream, "buffer", stream) + for line in lines: + buffer.write((line + "\n").encode("utf-8")) + buffer.flush() + + +def main(argv): + output_format = "art" + paths = [] + index = 0 + while index < len(argv): + argument = argv[index] + if argument == "--format": + index += 1 + if index >= len(argv) or argv[index] not in FORMATS: + print("--format must be one of: " + ", ".join(FORMATS)) + return 2 + output_format = argv[index] + elif argument == "--charset": + index += 1 + if index >= len(argv) or argv[index] not in charset.SETS: + print("--charset must be one of: " + ", ".join(sorted(charset.SETS))) + return 2 + charset.use(argv[index]) + else: + paths.append(argument) + index += 1 + + if not paths: + print(__doc__) + return 2 + + for path in paths: + write(render_file(path, output_format)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt b/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt new file mode 100644 index 0000000..0630c54 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt @@ -0,0 +1,32 @@ +PROGRAM FB_TESTING +VAR CONSTANT + uiMinVoltage : UINT := 5000; +END_VAR +VAR + fbSystemSupply : ifmIOcommon.SystemSupply; + fbSupplySwitch : ifmIOcommon.SupplySwitch; + uiCurrSupplyVolt : UINT; + TOF_0 : TOF; +END_VAR + +(* Network 1: Function Block to monitor supply voltage on VBB15 (from ignition) *) + fbSystemSupply : ifmIOcommon.SystemSupply + ┌─────────────────────────────────────────┐ +ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15──────┤eChannel xError│ +ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY──┤eMode eDiagInfo│ + │eFilter xPrepared│ + │ uiOutVoltage => uiCurrSupplyVolt│ + └─────────────────────────────────────────┘ + +(* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) + fbSupplySwitch : ifmIOcommon.SupplySwitch + ┌─────────────────────────────────────────┐ +ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH──┤eMode xError│ + GT TOF_0 : TOF │ │ + ┌──────────┐ ┌───────────┐ │ │ +uiCurrSupplyVolt──┤In1 Out1├──┤IN Q├─────┤xValue eDiagInfo│ +uiMinVoltage──────┤In2 │ │ │ │ xPrepared│ + └──────────┘ │ │ └─────────────────────────────────────────┘ +T#5S────────────────────────────┤PT ET│ + └───────────┘ + diff --git a/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt b/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt new file mode 100644 index 0000000..272b9fa --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt @@ -0,0 +1,19 @@ +PROGRAM FB_TESTING +VAR CONSTANT + uiMinVoltage : UINT := 5000; +END_VAR +VAR + fbSystemSupply : ifmIOcommon.SystemSupply; + fbSupplySwitch : ifmIOcommon.SupplySwitch; + uiCurrSupplyVolt : UINT; + TOF_0 : TOF; +END_VAR + +(* Network 1: Function Block to monitor supply voltage on VBB15 (from ignition) *) +fbSystemSupply(eChannel := ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15, eMode := ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY); +uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage; + +(* Network 2: Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge *) +TOF_0(IN := uiCurrSupplyVolt > uiMinVoltage, PT := T#5S); +fbSupplySwitch(eMode := ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH, xValue := TOF_0.Q); + diff --git a/tools/ladder/tests/fixtures/codesys/FbTesting.xml b/tools/ladder/tests/fixtures/codesys/FbTesting.xml new file mode 100644 index 0000000..1dec322 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.xml @@ -0,0 +1,325 @@ + + + + + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + + + + + + + + + + + + + + + ifm electronic + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + GraphicalTesting + false + ifmCR711S_TemplateProject + 2.4.10.0 + + + + + + + + + + + + + + + + + + + Minimum Voltage in mV + + + + + + + + + + Function Block to monitor supply voltage on VBB15 (from ignition) + + + + + + + + Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) + + + + + + + + Operating Voltage in mV + + + + + + + + + + + + + + + FBD Implementation Attributes + + + + + + + + + + + + + // Function Block to monitor supply voltage on VBB15 (from ignition) + + + + + + ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15 + + + + + ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + uiCurrSupplyVolt + + + + + + functionblock + + + SYS_VOLTAGE_CHANNEL MODE_SYSTEM_SUPPLY FILTER_INPUT + + + BOOL ifmTypes.DIAG_INFO BOOL UINT + + + + + + + //Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge + + + + + + ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH + + + + + uiCurrSupplyVolt + + + + + uiMinVoltage + + + + + + + + + + + + + + + + + + + + + + + + operator + + + + + + BOOL + + + + + + + T#5S + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + BOOL TIME + + + BOOL TIME + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + MODE_SUPPLY_SWITCH BOOL + + + BOOL ifmTypes.DIAG_INFO BOOL + + + + + + + + cedc2742-8922-46db-927d-5f652c9943c9 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt new file mode 100644 index 0000000..bc8a4ba --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt @@ -0,0 +1,25 @@ +PROGRAM LD_TEST +VAR + Sensor1 : BOOL; + Sensor2 : BOOL; + sensor3 : BOOL; + PowerOn : BOOL; + TON_0 : TON; + CTU_0 : CTU; + PowerOff : BOOL; +END_VAR + +(* Network 1 *) +│ Sensor1 Sensor2 PowerOn +├──┬───┤ ├───┬───┤/├──────(S)─────┤ +│ │ sensor3 │ +│ └───┤ ├───┘ + +(* Network 2 *) +│ TON_0 : TON CTU_0 : CTU +│ PowerOn ┌───────────────┐ ┌──────────────────────┐ PowerOff +├─────┤ ├────┤IN Q├──┤CU Q├────(R)──────┤ +│ │PT := T#5S ET│ │RESET := PowerOff CV│ +│ └───────────────┘ │PV := 10 │ +│ └──────────────────────┘ + diff --git a/tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt b/tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt new file mode 100644 index 0000000..5e22665 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt @@ -0,0 +1,19 @@ +PROGRAM LD_TEST +VAR + Sensor1 : BOOL; + Sensor2 : BOOL; + sensor3 : BOOL; + PowerOn : BOOL; + TON_0 : TON; + CTU_0 : CTU; + PowerOff : BOOL; +END_VAR + +(* Network 1 *) +IF (Sensor1 OR sensor3) AND NOT Sensor2 THEN PowerOn := TRUE; END_IF + +(* Network 2 *) +TON_0(IN := PowerOn, PT := T#5S); +CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10); +IF CTU_0.Q THEN PowerOff := FALSE; END_IF + diff --git a/tools/ladder/tests/fixtures/codesys/LDTesting.xml b/tools/ladder/tests/fixtures/codesys/LDTesting.xml new file mode 100644 index 0000000..45c882d --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.xml @@ -0,0 +1,271 @@ + + + + + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + + + + + + + + + + + + + + + ifm electronic + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + GraphicalTesting + false + ifmCR711S_TemplateProject + 2.4.10.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + networktitle + + + + + + + + + + Sensor1 + + + + + + + + sensor3 + + + + + + + + + Sensor2 + + + + + + + + PowerOn + + + + + + + + + + + + + + + networktitle + + + + + + + + + + PowerOn + + + + + T#5S + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + + PowerOff + + + + + 10 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + + PowerOff + + + + + + + + + + 4f4ad042-bbb9-4292-adb4-f91543e47fce + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/ladder/tests/fixtures/codesys/SFCTesting.xml b/tools/ladder/tests/fixtures/codesys/SFCTesting.xml new file mode 100644 index 0000000..6f45495 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/SFCTesting.xml @@ -0,0 +1,454 @@ + + + + + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + + + + + + + + + + + + + + + ifm electronic + Project template with CR711S configured 2.25MByte Standard 2.25MByte Safety + GraphicalTesting + false + ifmCR711S_TemplateProject + 2.4.10.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WaitingForTrain + FALSE + 0 + TRUE + FALSE + + + + + + + + + + + + + + + + + CmdBarriers + FALSE + 0 + R + + + + + + + + + + + + + + + + Branch0 + FALSE + FALSE + + + + + + + + SensorA + + + + + + + + + + + + + + + SensorA + FALSE + 0 + FALSE + 0 + + + + + + + + + + + + + + + TrainFromAboveA + FALSE + 0 + FALSE + FALSE + + + + + + + + + + + + + + + + + CmdBarriers + FALSE + 0 + S + + + + + + + + + SensorB + + + + + + + + + + + + + + + SensorB + FALSE + 0 + FALSE + 0 + + + + + + + + + + + + + + TrainFromAboveB + FALSE + 0 + FALSE + FALSE + + + + + + + + Not SensorB + + + + + + + + + + + + + + + Not SensorB + FALSE + 0 + FALSE + 0 + + + + + + + + SensorB + + + + + + + + + + + + + + + SensorB + FALSE + 0 + FALSE + 0 + + + + + + + + + + + + + + + TrainFromBelowB + FALSE + 0 + FALSE + FALSE + + + + + + + + + + + + + + + + + CmdBarriers + FALSE + 0 + S + + + + + + + + + SensorA + + + + + + + + + + + + + + + SensorA + FALSE + 0 + FALSE + 0 + + + + + + + + + + + + + + TrainFromBelowA + FALSE + 0 + FALSE + FALSE + + + + + + + + Not SensorA + + + + + + + + + + + + + + + Not SensorA + FALSE + 0 + FALSE + 0 + + + + + + + + + + + + + + + + + + + + + + + WaitingForTrain + FALSE + + + + + + + + + + IecSfc + System + 3.4.2.0 + IecSfc + false + false + + + + + + + + + + + + + + + + + + + + + d158652c-96fd-4ed4-aadb-28e74dfb74df + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/ladder/tests/fixtures/fbd_control_flow.plcopen.xml b/tools/ladder/tests/fixtures/fbd_control_flow.plcopen.xml new file mode 100644 index 0000000..81f9633 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd_control_flow.plcopen.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xInitDone + + + + + Mode.Current = Mode.ESTOP + + + + + + + + + + + + + + + + + + operator + + + + + + + + + + + + + + + RawPressure + + + + + 100 + + + + + + + + + + + + + + + + + + operator + + + + + + + + + + + + + + + + + + + operator + + + + + + + + + Status.PressureBar + + + + + + + + + + xInitDone + + + + + + + + + + + + + + + execute + + + IF NOT xInitDone THEN +Status.PressureBar := 0; +Status.Faulted := FALSE; +END_IF + + + + + + + + + + diff --git a/tools/ladder/tests/fixtures/fbd_fanout.plcopen.xml b/tools/ladder/tests/fixtures/fbd_fanout.plcopen.xml new file mode 100644 index 0000000..d58af15 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd_fanout.plcopen.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + Conveyor off is the opposite of conveyor on + + + + + + Flags.FwdSolOn + + + + + Flags.RevSolOn + + + + + + + + + + + + + + + + + + operator + + + + + + + + + Flags.ConvOn + + + + + + + Flags.ConvOff + + + + + + + Run timer + + + + + + xRun + + + + + T#5S + + + + + + + + + + + + + + + + + + + functionblock + + + + + + + + + Status.Done + + + + + + + Status.Latched + + + + + + + Raw.Level + + + + + Status.Level + + + + + + + + diff --git a/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml b/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml new file mode 100644 index 0000000..f9eb214 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd_fidelity.plcopen.xml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xIn + + + + + + + xInverted + + + + + + + xRun + + + + + xReady + + + + + + + + + + + + + + + + + + operator + + + + + + + + + + + + + + + + + + + + + xBoth + + + + + + + xRun2 + + + + + xReady2 + + + + + + + + + + + + + + + + + + operator + + + + + + + + + xMasked + + + + + + + xGo + + + + + + + + + + + + + xIdle + + + + + + + + + + xA OR xB + + + + + + + xGuard + + + + + + + iCount>5 + + + + + + + xHot + + + + + + + + diff --git a/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml b/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml new file mode 100644 index 0000000..aec150c --- /dev/null +++ b/tools/ladder/tests/fixtures/ld_fidelity.plcopen.xml @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xGo + + + + + + + + + + + + + + + + + + + + + + + + + xStart + + + + + xManual + + + + + + + + + + + + + + + + + + + + + + + + + + iCount + + + + + + + + + + + + xDone + + + + + + + + + + + + + + + + + + + + + + xPress + + + + + + + + xStop + + + + + + + + + + + + + + + xRun + + + + + + + + + + + + + + + + + xCool + + + + + + + + + + + + + + + + + + xB + + + + + + + + + + + + + + + + + + + + + + + + + + xGo2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xFin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/ladder/tests/fixtures/motor_control.expected.txt b/tools/ladder/tests/fixtures/motor_control.expected.txt new file mode 100644 index 0000000..983b232 --- /dev/null +++ b/tools/ladder/tests/fixtures/motor_control.expected.txt @@ -0,0 +1,26 @@ +PROGRAM Motor_Control +VAR + Start_PB : BOOL; + Stop_PB : BOOL; + Motor_Run : BOOL := FALSE; + Fault_In : BOOL; + Fault_Latch : BOOL; + Reset_PB : BOOL; + Ack : BOOL; + Run_Time : TON; +END_VAR + +(* Network 1 *) +│ Start_PB Stop_PB Motor_Run +├──┬───┤ ├─────┬───┤/├───────( )──────┤ +│ │ Motor_Run │ +│ └────┤ ├────┘ + +(* Network 2 *) +│ Fault_In Fault_Latch +├─────┤P├─────────(S)───────┤ + +(* Network 3 *) +│ Reset_PB Ack Fault_Latch +├─────┤ ├──────┤/├───────(R)───────┤ + diff --git a/tools/ladder/tests/fixtures/motor_control.plcopen.xml b/tools/ladder/tests/fixtures/motor_control.plcopen.xml new file mode 100644 index 0000000..2108190 --- /dev/null +++ b/tools/ladder/tests/fixtures/motor_control.plcopen.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Start_PB + + + + + + + + + Motor_Run + + + + + + + + + + + Stop_PB + + + + + + + + + Motor_Run + + + + + + + + + + + + + + + + + + + + + + Fault_In + + + + + + + + + Fault_Latch + + + + + + + + + + + + + + + + + + + + + + Reset_PB + + + + + + + + + Ack + + + + + + + + Fault_Latch + + + + + + + + diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py new file mode 100644 index 0000000..4cf8665 --- /dev/null +++ b/tools/ladder/tests/test_export.py @@ -0,0 +1,311 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Tests for the export-path bridge and the derived file's contract. + +The renderers being correct is not enough: the derived .txt must not disturb +Export To Files / Import From Files. These cover the parts that would break a +working project rather than just produce an ugly diagram. + + python tools/ladder/tests/test_export.py +""" + +from __future__ import print_function, unicode_literals + +import io +import os +import shutil +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.join(HERE, "..", "..", "..") +sys.path.insert(0, os.path.join(REPO, "src")) +sys.path.insert(0, os.path.join(REPO, "tools", "ci")) # stubbed scriptengine + +import graphical_export # noqa: E402 +import import_from_files # noqa: E402 + +FIXTURES = os.path.join(HERE, "fixtures", "codesys") + +failures = [] + + +def check(name, condition, detail=""): + if condition: + print("OK " + name) + else: + failures.append(name) + print("FAIL " + name + ((": " + detail) if detail else "")) + + +def check_equal(name, actual, expected): + check(name, actual == expected, "expected %r, got %r" % (expected, actual)) + + +class FakePou(object): + """Stands in for a CODESYS ScriptObject. + + export_xml hands back a fixture instead of talking to CODESYS, which is + exactly what the real call does from this module's point of view. + """ + + def __init__(self, name, source=None, declaration=None): + self._name = name + self._source = source + self.export_calls = [] + if declaration is not None: + self.textual_declaration = type("TextualDeclaration", (object,), {"text": declaration})() + + def get_name(self): + return self._name + + def export_xml(self, path, recursive, declarations_as_plaintext=None): + self.export_calls.append((path, recursive, declarations_as_plaintext)) + if self._source is None: + raise RuntimeError("export_xml exploded") + shutil.copyfile(self._source, path) + + +class OldScriptEnginePou(FakePou): + """A build without the declarations_as_plaintext overload. + + IronPython raises TypeError when no overload matches, which must fall back + to the plain call rather than losing the rendering. + """ + + def export_xml(self, path, recursive): + self.export_calls.append((path, recursive)) + shutil.copyfile(self._source, path) + + +class RecordingParent(object): + """Records anything the importer tries to do to the project.""" + + def __init__(self): + self.calls = [] + + def __getattr__(self, name): + def record(*args, **kwargs): + self.calls.append(name) + return RecordingParent() + + return record + + +def read(path): + handle = io.open(path, encoding="utf-8") + try: + return handle.read() + finally: + handle.close() + + +# --- the derived file is written next to the native xml -------------------- + +workspace = tempfile.mkdtemp() +try: + base = os.path.join(workspace, "LD_TEST") + pou = FakePou("LD_TEST", os.path.join(FIXTURES, "LDTesting.xml")) + + check("ladder pou is rendered", graphical_export.write_rendered_text(pou, base) is True) + check("derived file lands beside the xml", os.path.exists(base + ".txt")) + check_equal("export_xml is asked for a single object", pou.export_calls[0][1], False) + # Without this the declaration loses comments, pragmas and attributes. + check_equal("plaintext declarations are requested", pou.export_calls[0][2], True) + + content = read(base + ".txt") + check("derived file leads with the declaration", content.startswith("PROGRAM LD_TEST")) + # Diagram only. Rendering the same network twice, once as ST and once as a + # diagram, made the files harder to read rather than easier. + check("no ST rendering is written", "IF CTU_0.Q THEN PowerOff := FALSE; END_IF" not in content) + check("networks are numbered", "(* Network 1 *)" in content) + check("derived file contains the diagram", "TON_0 : TON" in content) + check("the declaration appears once", content.count("END_VAR") == 1) + check("derived file ends with a newline", content.endswith("\n")) + + # The temp PLCopen file is staged outside the export folder, so nothing but + # the rendering may appear next to the native xml. + check_equal("no stray files left behind", sorted(os.listdir(workspace)), ["LD_TEST.txt"]) + + # --- an older ScriptEngine without the plaintext overload --------------- + + old_base = os.path.join(workspace, "OLD") + old_pou = OldScriptEnginePou("OLD", os.path.join(FIXTURES, "LDTesting.xml")) + check("an older ScriptEngine still renders", graphical_export.write_rendered_text(old_pou, old_base) is True) + check("it fell back to the plain call", os.path.exists(old_base + ".txt")) + + # --- languages we cannot draw are skipped, not written empty ------------ + + sfc_base = os.path.join(workspace, "SFC_TEST") + sfc = FakePou("SFC_TEST", os.path.join(FIXTURES, "SFCTesting.xml")) + check("sfc reports nothing rendered", graphical_export.write_rendered_text(sfc, sfc_base) is False) + check("sfc writes no empty file", not os.path.exists(sfc_base + ".txt")) + + # --- cost reporting ----------------------------------------------------- + + # The ScriptEngine can keep modules loaded between runs, so without an + # explicit reset the summary would report totals accumulated across every + # Export click since CODESYS started. + # Two ladder POUs rendered by this point: the plain one and the one + # standing in for an older ScriptEngine. + check_equal("each render is counted", graphical_export.STATS["rendered"], 2) + check_equal("the skipped sfc is counted", graphical_export.STATS["skipped"], 1) + check("the summary names both costs", "CODESYS export_xml" in graphical_export.summary()) + + graphical_export.reset_stats() + check_equal("reset clears the counts", graphical_export.STATS["rendered"], 0) + check_equal("nothing to report after a reset", graphical_export.summary(), None) + + graphical_export.STATS["rendered"] = 1 + graphical_export.STATS["verbatim_declarations"] = 1 + graphical_export.STATS["fallback_declarations"] = 1 + check( + "mixed declaration sources are reported", + "1 POU declaration(s) were rebuilt" in graphical_export.summary(), + ) + graphical_export.reset_stats() + + source_declaration = """{attribute 'qualified_only'} +PROGRAM LD_TEST +VAR + S_xSafe : SAFEBOOL; + // OUT0200 is the hardware channel identifier. + uiChannel : UINT := 0200; +END_VAR""" + source_pou = FakePou("LD_TEST", os.path.join(FIXTURES, "LDTesting.xml"), source_declaration) + source_base = os.path.join(workspace, "SOURCE") + check("source declaration is rendered verbatim", graphical_export.write_rendered_text(source_pou, source_base) is True) + source_content = read(source_base + ".txt") + check("safety type survives", "S_xSafe : SAFEBOOL;" in source_content) + check("declaration comment survives", "OUT0200 is the hardware channel identifier." in source_content) + check("padded literal survives", "UINT := 0200;" in source_content) + check("declaration pragma survives", "{attribute 'qualified_only'}" in source_content) + + # --- a rendering failure must not fail the export ----------------------- + + broken_base = os.path.join(workspace, "BROKEN") + broken = FakePou("BROKEN", None) + check("a broken export is reported, not raised", graphical_export.write_rendered_text(broken, broken_base) is False) + check("broken pou writes no file", not os.path.exists(broken_base + ".txt")) + + # The barrier must also hold around its own scaffolding: a temp file that + # cannot be created (%TEMP% full) or removed (an antivirus scan holding it) + # is exactly the kind of environmental hiccup that must not abort a whole + # Export To Files run over a derived file. + + real_mkstemp = tempfile.mkstemp + + def failing_mkstemp(*args, **kwargs): + raise OSError("no temp space") + + tempfile.mkstemp = failing_mkstemp + try: + no_temp = FakePou("NO_TEMP", os.path.join(FIXTURES, "LDTesting.xml")) + try: + outcome = graphical_export.write_rendered_text(no_temp, os.path.join(workspace, "NO_TEMP")) + check("a temp-file creation failure is reported, not raised", outcome is False) + except Exception as error: + check("a temp-file creation failure is reported, not raised", False, repr(error)) + finally: + tempfile.mkstemp = real_mkstemp + + real_remove = os.remove + real_sticky_mkstemp = tempfile.mkstemp + stranded = [] + + def failing_remove(path): + raise OSError("sharing violation") + + def recording_mkstemp(*args, **kwargs): + result = real_sticky_mkstemp(*args, **kwargs) + stranded.append(result[1]) + return result + + tempfile.mkstemp = recording_mkstemp + os.remove = failing_remove + try: + sticky = FakePou("STICKY", os.path.join(FIXTURES, "LDTesting.xml")) + try: + outcome = graphical_export.write_rendered_text(sticky, os.path.join(workspace, "STICKY")) + check("a temp-file cleanup failure is reported, not raised", outcome is True) + except Exception as error: + check("a temp-file cleanup failure is reported, not raised", False, repr(error)) + finally: + os.remove = real_remove + tempfile.mkstemp = real_sticky_mkstemp + # The blocked cleanup deliberately strands the temp file; without this + # the suite leaks one orphan into the real temp directory per run. + for leaked in stranded: + if os.path.exists(leaked): + os.remove(leaked) + + # A write that dies halfway must not leave a truncated .txt behind: the + # staging folder is swapped into place wholesale, and a half-written + # rendering looks exactly like a valid one that misstates the logic. + real_open_utf8 = graphical_export.open_utf8 + + class FailingWriter(object): + def __init__(self, handle): + self._handle = handle + + def __enter__(self): + return self + + def __exit__(self, *args): + self._handle.close() + return False + + def write(self, text): + self._handle.write(text[: len(text) // 2]) + raise IOError("disk full") + + def failing_open_utf8(path, mode): + return FailingWriter(real_open_utf8(path, mode)) + + graphical_export.open_utf8 = failing_open_utf8 + try: + torn = FakePou("TORN", os.path.join(FIXTURES, "LDTesting.xml")) + torn_base = os.path.join(workspace, "TORN") + try: + outcome = graphical_export.write_rendered_text(torn, torn_base) + check("a mid-write failure is reported, not raised", outcome is False) + except Exception as error: + check("a mid-write failure is reported, not raised", False, repr(error)) + check("a truncated rendering is not left behind", not os.path.exists(torn_base + ".txt")) + finally: + graphical_export.open_utf8 = real_open_utf8 +finally: + shutil.rmtree(workspace) + + +# --- the importer ignores the derived file --------------------------------- + +# This is the contract that keeps the round trip intact. import_directory_child +# dispatches on ".xml" and ".st"; a ".txt" matches no branch. Asserting it here +# means a later change to that dispatch cannot silently start importing +# derived files. +workspace = tempfile.mkdtemp() +try: + for name in ("Main.txt", "Main.Method.txt", "Main.gvl.txt"): + handle = io.open(os.path.join(workspace, name), "w", encoding="utf-8") + handle.write("PROGRAM Main\n") + handle.close() + + parent = RecordingParent() + import_from_files.import_directory_child(name, workspace, parent) + check_equal("importer ignores " + name, parent.calls, []) + + # A control: the native xml alongside it must still import, or the test + # above would pass for the wrong reason. + shutil.copyfile(os.path.join(FIXTURES, "LDTesting.xml"), os.path.join(workspace, "Main.xml")) + parent = RecordingParent() + import_from_files.import_directory_child("Main.xml", workspace, parent) + check_equal("native xml still imports", parent.calls, ["import_native"]) +finally: + shutil.rmtree(workspace) + +print("") +if failures: + print("%d check(s) failed" % len(failures)) +else: + print("all checks passed") +sys.exit(1 if failures else 0) diff --git a/tools/ladder/tests/test_fbd.py b/tools/ladder/tests/test_fbd.py new file mode 100644 index 0000000..5810120 --- /dev/null +++ b/tools/ladder/tests/test_fbd.py @@ -0,0 +1,325 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Tests for the Function Block Diagram renderer and the ST emitter. + +Plain script rather than pytest, matching tools/ci/, so it runs under both +Python 3 and the IronPython 2.7 that CODESYS embeds. + + python tools/ladder/tests/test_fbd.py +""" + +from __future__ import print_function, unicode_literals + +import io +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +# The renderers live in src/ so CODESYS can load them; tools/ladder keeps only +# the dev CLI and these tests. +sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "src")) +sys.path.insert(0, os.path.join(HERE, "..")) + +import charset # noqa: E402 +import fbd_render # noqa: E402 +import parse_ld # noqa: E402 +import parse_fbd # noqa: E402 +import st_render # noqa: E402 +from model import Call, Network, Pou, Signal # noqa: E402 +from render import write # noqa: E402 + +# Referenced through the charset table rather than as literal glyphs: this +# source file has to stay pure ASCII for IronPython 2.7 to load it at all. +U = charset.UNICODE + +FIXTURES = os.path.join(HERE, "fixtures", "codesys") +FBD_SOURCE = os.path.join(FIXTURES, "FbTesting.xml") +LD_SOURCE = os.path.join(FIXTURES, "LDTesting.xml") +SFC_SOURCE = os.path.join(FIXTURES, "SFCTesting.xml") + +failures = [] + + +def check(name, condition, detail=""): + if condition: + print("OK " + name) + else: + failures.append(name) + print("FAIL " + name + ((": " + detail) if detail else "")) + + +def check_equal(name, actual, expected): + check(name, actual == expected, "expected %r, got %r" % (expected, actual)) + + +def check_golden(name, rendered, golden_path): + # Goldens hold box-drawing characters, so the encoding cannot be left to + # the platform default - and neither can printing them on a mismatch. + handle = io.open(golden_path, encoding="utf-8") + try: + expected = handle.read().replace("\r\n", "\n").rstrip("\n").split("\n") + finally: + handle.close() + if rendered != expected: + write(["--- expected ---"] + expected + ["--- actual ---"] + rendered) + check_equal(name, rendered, expected) + + +# --- parsing --------------------------------------------------------------- + +pous = parse_fbd.parse_pous(FBD_SOURCE) +check_equal("one FBD pou is found", len(pous), 1) + +pou = pous[0] +check_equal("pou name", pou.name, "FB_TESTING") +check_equal("language is recorded", pou.language, "FBD") +check_equal("two networks", len(pou.networks), 2) + +# localVars constant="true" is a separate group and must not merge with VAR. +check_equal("constant scope", pou.variables[0].scope, "VAR CONSTANT") +check_equal("constant initial value", pou.variables[0].initial_value, "5000") +check_equal("namespaced derived type", pou.variables[1].type_name, "ifmIOcommon.SystemSupply") + +# Comments carry the network's intent and nest their text in an xhtml element. +comment1, tree1 = pou.networks[0].comment, pou.networks[0].outputs[0] +check("network 1 comment is captured", comment1.startswith("// Function Block to monitor supply voltage")) + +hostile_comment = "// first\nsecond *) third" +check_equal( + "network comments cannot break generated block comments", + fbd_render.render_pou(Pou("HOSTILE", "program", networks=[Network(hostile_comment, [Signal("x")])]))[2], + "(* Network 1: first second * ) third *)", +) +check_equal( + "ST network comments cannot break generated block comments", + st_render._network_header(0, hostile_comment), + "(* Network 1: first second * ) third *)", +) + +check("network 1 is a call", isinstance(tree1, Call)) +check_equal("network 1 instance", tree1.instance_name, "fbSystemSupply") +check_equal("network 1 type", tree1.type_name, "ifmIOcommon.SystemSupply") +check_equal("network 1 has three inputs", len(tree1.inputs), 3) +check_equal("first pin name", tree1.inputs[0][0], "eChannel") +check("first pin source is a signal", isinstance(tree1.inputs[0][1], Signal)) +check_equal("first pin value", tree1.inputs[0][1].label, "ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15") + +# An unconnected pin exports as an element with an empty expression, not as a +# missing element - so it reaches the tree as a Signal carrying no label. +check_equal("unwired pin name", tree1.inputs[2][0], "eFilter") +check_equal("unwired pin has an empty label", tree1.inputs[2][1].label, "") +check("unwired pin is not drawn with a wire", not fbd_render._is_wired(tree1.inputs[2][1])) + +# CODESYS writes an assignment straight onto the output pin. +outputs = dict(tree1.outputs) +check_equal("output assignment is captured", outputs["uiOutVoltage"], "uiCurrSupplyVolt") + +# Network 2 nests three calls: GT -> TOF -> SupplySwitch. +comment2, tree2 = pou.networks[1].comment, pou.networks[1].outputs[0] +check_equal("network 2 root", tree2.instance_name, "fbSupplySwitch") +tof = tree2.inputs[1][1] +check_equal("nested TOF", tof.instance_name, "TOF_0") +check_equal("wire leaves TOF on Q", tof.active_output, "Q") +gt = tof.inputs[0][1] +check_equal("nested GT", gt.type_name, "GT") + +# An operator has no instance name, so it inlines as an expression in ST. +check("GT is an operator", gt.is_operator) +check("TOF is not an operator", not tof.is_operator) +check_equal("operator title omits the instance", gt.title, "GT") +check_equal("function block title includes it", tof.title, "TOF_0 : TOF") + +# --- FBD rendering --------------------------------------------------------- + +art = fbd_render.render_pou(pou) +check("art: no trailing whitespace", all(line == line.rstrip() for line in art)) +check("art: boxes do not fuse together", not any(U["TR"] + U["TL"] in line for line in art)) +check("art: output assignment is drawn", any("uiOutVoltage => uiCurrSupplyVolt" in line for line in art)) +check("art: nested operator box is drawn", any(U["PIN_L"] + "In1 Out1" + U["PIN_R"] in line for line in art)) + +# A tee marks a real connection, so an unconsumed output must leave the wall +# unbroken. fbSupplySwitch is a network sink: nothing takes its xError. +check("art: sink output is not teed", any("xError" + U["V"] in line for line in art)) +check("art: consumed output is teed", any("Out1" + U["PIN_R"] in line for line in art)) + +# Every position in this export is x="0" y="0". If layout depended on those +# coordinates the three boxes would land on top of each other, so finding each +# title on its own distinct row is what proves layout comes from topology. +title_rows = {} +for row, line in enumerate(art): + for title in ("GT", "TOF_0 : TOF", "fbSupplySwitch : ifmIOcommon.SupplySwitch"): + if title in line and title not in title_rows: + title_rows[title] = row +check_equal("art: all three boxes are placed", len(title_rows), 3) +check_equal("art: no two boxes share a row", len(set(title_rows.values())), 3) + +check_golden("art: golden output matches", art, os.path.join(FIXTURES, "FbTesting.art.expected.txt")) + +# --- ST emission ----------------------------------------------------------- + +fbd_st = st_render.render_pou(pou) + +SUPPLY_CALL = ( + "fbSystemSupply(eChannel := ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15," + " eMode := ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY);" +) +SWITCH_CALL = "fbSupplySwitch(eMode := ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH, xValue := TOF_0.Q);" + +check("st: function block becomes a call statement", SUPPLY_CALL in fbd_st) +check("st: output assignment becomes its own statement", "uiCurrSupplyVolt := fbSystemSupply.uiOutVoltage;" in fbd_st) +check("st: comparison operator inlines infix", "TOF_0(IN := uiCurrSupplyVolt > uiMinVoltage, PT := T#5S);" in fbd_st) +check("st: nested output is referenced by pin", SWITCH_CALL in fbd_st) +check("st: unwired pin is omitted", not any("eFilter" in line for line in fbd_st)) + +check_golden("st: FBD golden matches", fbd_st, os.path.join(FIXTURES, "FbTesting.st.expected.txt")) + +ld_pou = parse_ld.parse_pous(LD_SOURCE)[0] +ld_st = st_render.render_pou(ld_pou) +check("st: parallel branch becomes OR", "IF (Sensor1 OR sensor3) AND NOT Sensor2 THEN PowerOn := TRUE; END_IF" in ld_st) +check("st: ladder block becomes a call", "TON_0(IN := PowerOn, PT := T#5S);" in ld_st) +check("st: block chains through its output pin", "CTU_0(CU := TON_0.Q, RESET := PowerOff, PV := 10);" in ld_st) +check("st: reset coil becomes a conditional", "IF CTU_0.Q THEN PowerOff := FALSE; END_IF" in ld_st) + +check_golden("st: LD golden matches", ld_st, os.path.join(FIXTURES, "LDTesting.st.expected.txt")) + +# --- control flow ---------------------------------------------------------- + +# Everything below was silently dropped before, which is worse than failing: +# the rendering looked complete while a guard clause and a body of inline ST +# were simply absent. +CONTROL_FLOW = os.path.join(HERE, "fixtures", "fbd_control_flow.plcopen.xml") +flow = parse_fbd.parse_pous(CONTROL_FLOW)[0] +flow_st = st_render.render_pou(flow) +flow_art = fbd_render.render_pou(flow) + +check_equal("flow: four networks survive", len(flow.networks), 4) + +# A jump terminates a network. Leaving it out of SINK_KINDS dropped the entire +# guard network, because nothing else consumed the OR feeding it. +check("flow: the guard network is not dropped", any("JMP END" in line for line in flow_st)) +check("flow: the jump condition is kept", any("Mode.Current = Mode.ESTOP" in line for line in flow_st)) +check("flow: the jump target is drawn", any(">> END" in line for line in flow_art)) +check("flow: the label is shown", any("(* label: END *)" in line for line in flow_st)) + +# negated="true" on an inVariable inverts the logic if it is ignored. +guard = flow.networks[0].outputs[0] +check_equal("flow: negation reaches the tree", guard.condition.inputs[0][1].negated, True) +check_equal("flow: negation renders", guard.condition.inputs[0][1].text, "NOT xInitDone") +check("flow: negation survives into ST", any("(NOT xInitDone) OR" in line for line in flow_st)) + +# An EXECUTE box is nothing but inline ST; drawing the box alone loses it all. +execute = flow.networks[3].outputs[0] +check_equal("flow: inline ST is captured", len(execute.st_code), 4) +check("flow: inline ST reaches the ST output", any("Status.Faulted := FALSE;" in line for line in flow_st)) +# The EN pin genuinely guards the box, so it has to show up as a condition +# rather than being dropped for looking redundant. +check("flow: the EN guard wraps the inline ST", any(line == "IF xInitDone THEN" for line in flow_st)) +check("flow: inline ST reaches the diagram", any("Status.Faulted := FALSE;" in line for line in flow_art)) + +# Operators read as operators, not as function calls. +check("flow: arithmetic inlines infix", any("RawPressure / 100" in line for line in flow_st)) +check("flow: conversions stay function calls", any("REAL_TO_UINT(" in line for line in flow_st)) +check( + "flow: compound operands are bracketed", + any("(NOT xInitDone) OR (Mode.Current = Mode.ESTOP)" in line for line in flow_st), +) + + +# --- logic fidelity ---------------------------------------------------------- + +# Shapes whose mishandling renders the *inverse* of the program, or fabricates +# logic that is not there. For a review artifact that is worse than a crash. +FIDELITY = os.path.join(HERE, "fixtures", "fbd_fidelity.plcopen.xml") +fid = parse_fbd.parse_pous(FIDELITY)[0] +fid_st = st_render.render_pou(fid) +fid_art = fbd_render.render_pou(fid) + +# A connector terminates its network, so all seven must survive. +check_equal("fidelity: all seven networks survive", len(fid.networks), 7) + +# negated="true" on an outVariable inverts the logic if it is dropped. +check("fidelity: negated output inverts in ST", any("xInverted := NOT xIn;" in line for line in fid_st)) +check("fidelity: negated output is marked in the diagram", any("o> xInverted" in line for line in fid_art)) + +# A connector names a wire; the continuation re-emits it. Before these were +# handled, the AND network vanished and the consumer rendered "xBoth := FALSE;" +# - fabricated logic, not just missing logic. +check("fidelity: connector network keeps its logic", any("C1 := xRun AND xReady;" in line for line in fid_st)) +check("fidelity: continuation resolves to the named wire", any("xBoth := C1;" in line for line in fid_st)) +check("fidelity: nothing is fabricated as FALSE", not any(":= FALSE" in line for line in fid_st)) +check("fidelity: the connector's source reaches the diagram", any("xRun" in line for line in fid_art)) + +# The negation bubble on a block's own input pin, distinct from a negated +# inVariable element. Dropping it computes AND where the program computes +# AND NOT. +check("fidelity: negated input pin inverts in ST", any("xMasked := xRun2 AND (NOT xReady2);" in line for line in fid_st)) +check("fidelity: negated input pin reaches the diagram", any("NOT" in line and "xReady2" in line for line in fid_art)) + +# The same bubble on an output pin carrying an inline assignment: the stored +# value is the inverse of the pin. +check("fidelity: negated output pin inverts its assignment", any("xIdle := NOT tmr.Q;" in line for line in fid_st)) +check("fidelity: negated output pin is marked in the diagram", any("Q =o> xIdle" in line for line in fid_art)) + +# NOT binds tighter than OR in IEC 61131-3, so a negated compound expression +# must keep its parentheses or the logic regroups. +check("fidelity: negated compound expression keeps its grouping", any("xGuard := NOT (xA OR xB);" in line for line in fid_st)) + +# Expressions are free-form ST and are routinely typed without spaces; NOT +# still binds above the comparison, so "NOT iCount>5" states (NOT iCount)>5. +check("fidelity: spaceless compound keeps its grouping", any("xHot := NOT (iCount>5);" in line for line in fid_st)) + + +# --- fan-out --------------------------------------------------------------- + +# One source driving several outputs is a single network in the editor. +# Treating each output as its own network split every one of them in two and +# duplicated the shared expression, so the numbering disagreed with CODESYS. +FANOUT = os.path.join(HERE, "fixtures", "fbd_fanout.plcopen.xml") +fan = parse_fbd.parse_pous(FANOUT)[0] +fan_st = st_render.render_pou(fan) +fan_art = fbd_render.render_pou(fan) + +check_equal("fanout: three networks, not five", len(fan.networks), 3) +check_equal("fanout: the OR drives two outputs", len(fan.networks[0].outputs), 2) +check_equal("fanout: the timer drives two outputs", len(fan.networks[1].outputs), 2) +check_equal("fanout: a plain network keeps one", len(fan.networks[2].outputs), 1) + +# Both outputs of a network sit under its one header, with its one comment. +header_rows = [row for row, line in enumerate(fan_st) if line.startswith("(* Network")] +check_equal("fanout: three headers, not five", len(header_rows), 3) +check("fanout: the comment lands on the network", "Conveyor off is the opposite" in fan_st[header_rows[0]]) +check_equal( + "fanout: both stores share a header", + fan_st[header_rows[0] + 1 : header_rows[0] + 3], + [ + "Flags.ConvOn := Flags.FwdSolOn OR Flags.RevSolOn;", + "Flags.ConvOff := NOT (Flags.FwdSolOn OR Flags.RevSolOn);", + ], +) + +# The sharper case: the block is called once in the program, so emitting the +# call per output would misstate what runs. +check_equal("fanout: the block is called once", len([l for l in fan_st if l.startswith("TON_0(")]), 1) +check("fanout: both stores are still made", "Status.Done := TON_0.Q;" in fan_st and "Status.Latched := TON_0.Q;" in fan_st) + +# The shared source is drawn once and branched, not drawn per output. +check_equal("fanout: one OR box is drawn", len([l for l in fan_art if "In1 Out1" in l]), 1) +check("fanout: the branch is drawn", any(U["T_DOWN"] in l and "Flags.ConvOn" in l for l in fan_art)) +check("fanout: the negated leg keeps its bubble", any(U["BL"] in l and "o Flags.ConvOff" in l for l in fan_art)) + +# Identity, not equality, is what tells a fan-out from two equal expressions. +first, second = fan.networks[0].outputs +check("fanout: shared nodes are one object", first.source is second.source) + + +# --- language dispatch ----------------------------------------------------- + +check_equal("LD parser ignores FBD bodies", parse_ld.parse_pous(FBD_SOURCE), []) +check_equal("FBD parser ignores LD bodies", parse_fbd.parse_pous(LD_SOURCE), []) +check_equal("SFC is skipped by both", parse_ld.parse_pous(SFC_SOURCE) + parse_fbd.parse_pous(SFC_SOURCE), []) + +print("") +if failures: + print("%d check(s) failed" % len(failures)) +else: + print("all checks passed") +sys.exit(1 if failures else 0) diff --git a/tools/ladder/tests/test_ladder.py b/tools/ladder/tests/test_ladder.py new file mode 100644 index 0000000..ca64ae8 --- /dev/null +++ b/tools/ladder/tests/test_ladder.py @@ -0,0 +1,487 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Tests for the Ladder Diagram renderer. + +Written as a plain script rather than pytest, matching tools/ci/, so it runs +under both Python 3 and the IronPython 2.7 that CODESYS embeds. The renderer +is destined for src/ once it is proven, and it has to pass there too. + + python tools/ladder/tests/test_ladder.py +""" + +from __future__ import print_function, unicode_literals + +import io +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +# The renderers live in src/ so CODESYS can load them; tools/ladder keeps only +# the dev CLI and these tests. +sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "src")) +sys.path.insert(0, os.path.join(HERE, "..")) + +import charset # noqa: E402 +from ld_render import render_declaration, render_pou # noqa: E402 +from model import COIL, CONTACT, Element, Parallel, Series # noqa: E402 +from parse_ld import parse_pous # noqa: E402 +from render import write # noqa: E402 + +FIXTURES = os.path.join(HERE, "fixtures") +SOURCE = os.path.join(FIXTURES, "motor_control.plcopen.xml") +EXPECTED = os.path.join(FIXTURES, "motor_control.expected.txt") + +failures = [] + + +def check(name, condition, detail=""): + if condition: + print("OK " + name) + else: + failures.append(name) + print("FAIL " + name + ((": " + detail) if detail else "")) + + +def check_equal(name, actual, expected): + check(name, actual == expected, "expected %r, got %r" % (expected, actual)) + + +# --- parsing --------------------------------------------------------------- + +pous = parse_pous(SOURCE) + +check_equal("one LD pou is found", len(pous), 1) + +pou = pous[0] +check_equal("pou name", pou.name, "Motor_Control") +check_equal("pou type", pou.pou_type, "program") +check_equal("interface variables", len(pou.variables), 8) +check_equal("derived type resolves to its name", pou.variables[-1].type_name, "TON") +check_equal("initial value is captured", pou.variables[2].initial_value, "FALSE") + +check_equal("three rungs", len(pou.rungs), 3) + +# Network 1: (Start_PB OR Motor_Run) AND NOT Stop_PB -> Motor_Run +rung1 = pou.rungs[0] +check("rung 1 is a series", isinstance(rung1, Series)) +check_equal("rung 1 has three stages", len(rung1.items), 3) +check("rung 1 opens with a parallel branch", isinstance(rung1.items[0], Parallel)) +check_equal("seal-in has two branches", len(rung1.items[0].branches), 2) +check_equal("first branch is Start_PB", rung1.items[0].branches[0].label, "Start_PB") +check_equal("second branch is Motor_Run", rung1.items[0].branches[1].label, "Motor_Run") +check("Stop_PB is negated", rung1.items[1].negated) +check_equal("Stop_PB is a contact", rung1.items[1].kind, CONTACT) +check_equal("rung 1 terminates in a coil", rung1.items[2].kind, COIL) +check_equal("coil drives Motor_Run", rung1.items[2].label, "Motor_Run") + +# The right power rail anchors the rung but must not become a drawn element. +check( + "right power rail is not drawn", + all(not (isinstance(i, Element) and i.kind.endswith("PowerRail")) for i in rung1.items), +) + +# Network 2: rising edge into a set coil +rung2 = pou.rungs[1] +check_equal("rising edge is captured", rung2.items[0].edge, "rising") +check_equal("set coil storage", rung2.items[1].storage, "set") + +# Network 3: terminal is the coil itself, with no right power rail +rung3 = pou.rungs[2] +check_equal("rung 3 has three stages", len(rung3.items), 3) +check_equal("reset coil storage", rung3.items[2].storage, "reset") + +# --- layout independence --------------------------------------------------- + +handle = io.open(SOURCE, encoding="utf-8") +try: + source_text = handle.read() +finally: + handle.close() + +# Shifting every element 500px right must not change a single character of +# output. This is the property that keeps diffs meaningful. +moved = source_text.replace('>?" and +# emitted no ST for the whole rung, guard included. +check("fidelity: jump target is drawn", any(">>SKIP" in line for line in fidelity_art)) +check("fidelity: guarded jump reaches ST", any("IF xGo THEN (* JMP SKIP *) END_IF" in line for line in fidelity_st)) +check("fidelity: label is drawn", any("SKIP:" in line for line in fidelity_art)) +check("fidelity: label reaches ST", any("(* label: SKIP *)" in line for line in fidelity_st)) + +# model.Signal's docstring warns that dropping negated inverts the logic; the +# LD block-pin path did exactly that. +check("fidelity: negated pin keeps its NOT in ST", any("RESET := NOT xManual" in line for line in fidelity_st)) +check("fidelity: negated pin keeps its NOT in the box", any("RESET := NOT xManual" in line for line in fidelity_art)) + +# An assignment on a block output pin executes every scan; the diagram drew it +# but the ST - the half reviewers are told to trust - left it out. +check("fidelity: output pin assignment reaches ST", any("iCount := ctr.CV;" in line for line in fidelity_st)) +check("fidelity: output pin assignment is drawn", any("CV => iCount" in line for line in fidelity_art)) + +# A rung can store through an outVariable element instead of a coil - the +# standard shape for a non-boolean result. It emitted no ST at all, and a +# negated one lost its NOT in the diagram too. +check("fidelity: outVariable store reaches ST", any("xStop := NOT xPress;" in line for line in fidelity_st)) +check("fidelity: negated outVariable is marked in the diagram", any("[NOT xStop]" in line for line in fidelity_art)) + +# The negation bubble on the block's own pins: a negated power input and a +# negated, assigned output pin. Both inverted silently. +check("fidelity: negated power pin inverts in ST", any("tmr2(IN := NOT xRun);" in line for line in fidelity_st)) +check("fidelity: negated output pin inverts its assignment", any("xCool := NOT tmr2.Q;" in line for line in fidelity_st)) +check("fidelity: negated output pin is marked in the diagram", any("Q =o> xCool" in line for line in fidelity_art)) + +# A negated output consumed through a SIDE PIN goes via expr_to_text, a +# different path from the power flow - it must keep the NOT too. +check("fidelity: negated output survives into a side pin", any("RESET := xB AND NOT tmrA.Q" in line for line in fidelity_st)) +check("fidelity: side pin caption matches the ST", any("RESET := xB AND NOT tmrA.Q" in line for line in fidelity_art)) + +# A negated wired output feeding a coil, and only one bubble drawn for it. +check("fidelity: negated wired output inverts the coil", any("xFin := NOT ctr2.Q;" in line for line in fidelity_st)) +check("fidelity: no double bubble on a wired negated output", not any("Q oo" in line for line in fidelity_art)) + +# A negated power pin fed straight from the rail still states its inversion, +# instead of emitting a bare call identical to the un-negated case. +check("fidelity: rail-fed negated power pin is stated", any("tmrD(IN := NOT TRUE);" in line for line in fidelity_st)) + + +# --- byte order mark ------------------------------------------------------- + +# CODESYS writes a BOM on every export_xml file, and the ElementTree it ships +# in ScriptLib rejects one outright. CPython's expat accepts it silently, and +# so does stock IronPython, so no amount of CI could catch this by parsing +# alone - it only reproduces inside CODESYS. Asserting on the bytes handed to +# the parser is what makes it catchable here. +import plcopen # noqa: E402 + +BOM = b"\xef\xbb\xbf" +CODESYS_FIXTURES = os.path.join(FIXTURES, "codesys") + +bom_fixtures = 0 +for name in sorted(os.listdir(CODESYS_FIXTURES)): + if not name.endswith(".xml"): + continue + bom_fixtures += 1 + path = os.path.join(CODESYS_FIXTURES, name) + raw = open(path, "rb").read() + # The fixtures are real exports, so they should still carry their BOM. If + # one loses it, this test stops proving anything. + check(name + " is a real export, BOM and all", raw.startswith(BOM)) + check_equal(name + " is fed to the parser without its BOM", plcopen.read_document(path)[:1], b"<") + +# If the fixtures move, the loop above runs zero times and the BOM contract - +# the one that only reproduces inside CODESYS - silently stops being tested. +check("the BOM sweep found the real exports", bom_fixtures >= 3, "found %d" % bom_fixtures) + +check_equal( + "leading whitespace is dropped too", + plcopen.read_document(io.BytesIO(BOM + b"\n ")), + b"", +) +check_equal( + "a document with no BOM is untouched", + plcopen.read_document(io.BytesIO(b"")), + b"", +) + + +# --- non-ASCII content ----------------------------------------------------- + +# The parser CODESYS ships works byte-wise and rejects UTF-8 multi-byte +# sequences, so one degree sign in a comment loses the whole POU. Numeric +# character references are ASCII and every parser expands them identically. +DEGREE = b'Temp \xc2\xb0C' + +check_equal( + "non-ASCII becomes a numeric character reference", + plcopen.read_document(io.BytesIO(DEGREE)), + b"Temp °C", +) +check("escaped bytes are pure ASCII", all(b < 128 for b in bytearray(plcopen.read_document(io.BytesIO(DEGREE))))) + +# The whole point: the parsed text must come back unchanged. +import xml.etree.ElementTree as ET # noqa: E402 + +check_equal( + "the character survives the round trip", + ET.fromstring(plcopen.read_document(io.BytesIO(DEGREE)))[0].text, + u"Temp \u00b0C", +) +check_equal( + "pure ASCII documents are left alone", + plcopen.read_document(io.BytesIO(b"plain")), + b"plain", +) + +# A failure has to explain itself, so the next CODESYS run needs no separate +# diagnostic script. +handle = io.open(os.path.join(FIXTURES, "codesys", "LDTesting.xml"), "rb") +try: + clean = handle.read() +finally: + handle.close() + +import tempfile # noqa: E402 + +descriptor, suspect_path = tempfile.mkstemp(suffix=".xml") +try: + os.write(descriptor, clean.replace(b" has nowhere to put a comment, a pragma or an +# attribute. export_xml(declarations_as_plaintext=True) carries the real text, +# and a pragma like {attribute 'qualified_only'} changes what the code means - +# so paraphrasing it away is worse than not showing it. +DECLARATION = """{attribute 'qualified_only'} +PROGRAM PLAIN +VAR + xStart : BOOL; // start button, NO contact + (* the seal-in *) + xRun : BOOL := FALSE; +END_VAR""" + + +def with_interface(interface): + body = '' + body += '' + body += "xRun" + document = '' + document += interface + body + "" + return io.BytesIO(document.encode("utf-8")) + + +PLAINTEXT_INTERFACE = ( + "" + '' + "" + DECLARATION + "" +) +STRUCTURED_INTERFACE = '' + +plain_pou = parse_pous(with_interface(PLAINTEXT_INTERFACE))[0] +check_equal("the plaintext declaration is picked up", plain_pou.declaration_text, DECLARATION) + +declaration = render_declaration(plain_pou) +check_equal("it is used verbatim, line for line", declaration, DECLARATION.split("\n")) +check("a pragma survives", any("{attribute 'qualified_only'}" in line for line in declaration)) +check("a line comment survives", any("// start button, NO contact" in line for line in declaration)) +check("a block comment survives", any("(* the seal-in *)" in line for line in declaration)) + +# It has to reach the rendered file, not just the model. +check_equal("the rendering leads with it", render_pou(plain_pou)[0], "{attribute 'qualified_only'}") + +# Older exports carry no plaintext, and must still render something. +structured_pou = parse_pous(with_interface(STRUCTURED_INTERFACE))[0] +check_equal("no plaintext means none is invented", structured_pou.declaration_text, None) +check_equal("the structured interface is the fallback", render_declaration(structured_pou)[0], "PROGRAM PLAIN") +check("the fallback still lists the variable", any("xStart : BOOL;" in line for line in render_declaration(structured_pou))) + +# The shape CODESYS actually writes, confirmed by diagnosing a real project: +# a data element named ".../interfaceasplaintext", sitting at POU level rather +# than inside despite the name, with the text nested below it. +# The first two attempts at this searched only inside , and then +# only two levels down. +REAL_SHAPE = ( + "" + "' + + DECLARATION + + "" +) + + +def with_pou_level_add_data(extra): + body = '' + body += '' + body += "xRun" + document = '' + document += extra.replace("", "" + body, 1) + "" + return io.BytesIO(document.encode("utf-8")) + + +real_pou = parse_pous(with_pou_level_add_data(REAL_SHAPE))[0] +check_equal("the real CODESYS shape is found", real_pou.declaration_text, DECLARATION) +check("nested text is reached, not just two levels", "// start button, NO contact" in (real_pou.declaration_text or "")) + +# The addData element name is a proprietary extension that has moved between +# CODESYS versions, so the lookup matches on shape rather than on a name that +# would silently fall back to the lossy path if it ever changed again. +RENAMED = PLAINTEXT_INTERFACE.replace("Declarations", "DeclarationText").replace( + "plcopenxml/declarations", "plcopenxml/pou-declaration" +) +check_equal( + "a renamed addData element is still found", + parse_pous(with_interface(RENAMED))[0].declaration_text, + DECLARATION, +) + +DECOY = 'VAR fake END_VAR' +AMBIGUOUS = PLAINTEXT_INTERFACE.replace("", "" + DECOY, 1) +check_equal( + "ambiguous declaration-like addData is rejected", + parse_pous(with_interface(AMBIGUOUS))[0].declaration_text, + None, +) + + +# --- real CODESYS export --------------------------------------------------- + +# Exported from CODESYS V3.5 SP11 via Project > Export > PLCopenXML. This is +# the dialect that actually matters; the hand-authored fixture above only +# covers what the spec says. +CODESYS_SOURCE = os.path.join(FIXTURES, "codesys", "LDTesting.xml") +CODESYS_EXPECTED = os.path.join(FIXTURES, "codesys", "LDTesting.expected.txt") + +codesys_pous = parse_pous(CODESYS_SOURCE) +check_equal("codesys: one LD pou", len(codesys_pous), 1) + +ld_test = codesys_pous[0] +check_equal("codesys: pou name", ld_test.name, "LD_TEST") +check_equal("codesys: two networks", len(ld_test.rungs), 2) + +# CODESYS writes edge="none"/storage="none" rather than omitting the attribute. +network1 = ld_test.rungs[0] +check_equal("codesys: literal 'none' edge is normalised away", network1.items[1].edge, None) +check("codesys: negated contact survives", network1.items[1].negated) +check_equal("codesys: set coil", network1.items[2].storage, "set") + +# typeName and instanceName are attributes in CODESYS's output. Reading them as +# child elements is what produced "[?]" boxes on the first run. +network2 = ld_test.rungs[1] +blocks = [item for item in network2.items if getattr(item, "kind", None) == "block"] +check_equal("codesys: two blocks in network 2", len(blocks), 2) +check_equal("codesys: block type name", blocks[0].type_name, "TON") +check_equal("codesys: block instance name", blocks[0].instance_name, "TON_0") +check_equal("codesys: block title", blocks[0].title, "TON_0 : TON") + +# The power pin sorts first and carries no caption; parameter pins carry one. +check_equal("codesys: TON power pin is IN", blocks[0].input_pins[0], ("IN", None)) +check_equal("codesys: TON PT is a parameter", blocks[0].input_pins[1], ("PT", "T#5S")) + +# A second wired input cannot be drawn as another horizontal wire, so it is +# flattened to text inside the pin. +check_equal("codesys: CTU power pin is CU", blocks[1].input_pins[0], ("CU", None)) +check_equal("codesys: CTU RESET is flattened to text", blocks[1].input_pins[1], ("RESET", "PowerOff")) +check_equal("codesys: CTU PV is a literal", blocks[1].input_pins[2], ("PV", "10")) + +# The consumer's connection names the output pin it draws from. +check_equal("codesys: active output follows the wire", blocks[0].active_output, "Q") +check_equal("codesys: active output sorts first", blocks[0].output_pins[0][0], "Q") + +codesys_rendered = render_pou(ld_test) +check("codesys: no trailing whitespace", all(line == line.rstrip() for line in codesys_rendered)) +check_golden("codesys: golden output matches", codesys_rendered, CODESYS_EXPECTED) + +print("") +if failures: + print("%d check(s) failed" % len(failures)) +else: + print("all checks passed") +sys.exit(1 if failures else 0) diff --git a/tools/ladder/tests/test_xmlbackend.py b/tools/ladder/tests/test_xmlbackend.py new file mode 100644 index 0000000..6c686f4 --- /dev/null +++ b/tools/ladder/tests/test_xmlbackend.py @@ -0,0 +1,184 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Tests for the XML backend, and for the two backends agreeing. + +The golden files are generated under CPython with ElementTree and consumed by +CODESYS with System.Xml. If the backends disagree anywhere, CODESYS silently +renders something the goldens never saw. So the important test here can only +run where both backends exist - the IronPython CI job - and it is written to +report loudly when it is skipped rather than passing quietly. + + python tools/ladder/tests/test_xmlbackend.py +""" + +from __future__ import print_function, unicode_literals + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "..", "..", "src")) +sys.path.insert(0, os.path.join(HERE, "..")) + +import plcopen # noqa: E402 +import xmlbackend # noqa: E402 + +FIXTURES = os.path.join(HERE, "fixtures") +CODESYS = os.path.join(FIXTURES, "codesys") + +failures = [] + + +def check(name, condition, detail=""): + if condition: + print("OK " + name) + else: + failures.append(name) + print("FAIL " + name + ((": " + detail) if detail else "")) + + +def check_equal(name, actual, expected): + check(name, actual == expected, "expected %r, got %r" % (expected, actual)) + + +def every_fixture(): + paths = [] + for folder in (FIXTURES, CODESYS): + for name in sorted(os.listdir(folder)): + if name.endswith(".xml"): + paths.append(os.path.join(folder, name)) + return paths + + +SAMPLE = ( + b'' + b'' + b"texttail" + b"" + b"" +) + + +# --- the active backend behaves like ElementTree --------------------------- + +print("available backends: " + ", ".join(xmlbackend.available())) +print("active backend: " + xmlbackend.active()) + +root = xmlbackend.parse(SAMPLE) + +check_equal("namespace is stripped from the tag", plcopen.tag(root), "root") +first = list(root)[0] +check_equal("attributes read back", first.get("name"), "one") +# "not written" and "written empty" are different, and callers rely on it. +check_equal("an absent attribute is None", first.get("missing"), None) +check_equal("an empty attribute is not None", first.get("empty"), "") +check_equal("a default is honoured", first.get("missing", "fallback"), "fallback") +# Leading text only, as ElementTree defines it - not a flattened InnerText, +# which would fold "tail" in and make the backends disagree. +check_equal("text stops at the first child element", first.text, "text") +check_equal("an element with no text is None", list(root)[1].text, None) +check_equal("iteration yields child elements", len(list(root)), 2) +check_equal("iter walks the whole tree", len(list(root.iter())), 4) +# Pre-order, like ElementTree - a stack walk is easy to get backwards. +check_equal("iter is in document order", [plcopen.tag(e) for e in root.iter()], ["root", "a", "b", "a"]) +# Children are cached per element, so repeated find_child calls stay cheap. +check("repeated iteration is stable", list(root)[0] is list(root)[0]) + +# POUs are found without walking the document, but an unusual layout must +# still work rather than silently rendering nothing. +NESTED = b'' +check_equal( + "a pou outside types/pous is still found", + [p.get("name") for p in plcopen.find_pous(xmlbackend.parse(NESTED))], + ["X"], +) + + +# --- the two backends must agree ------------------------------------------- + + +def first_difference(left, right, path="/"): + """Where two trees first disagree, or None. Reported, not just counted. + + A bare "the trees differ" sends whoever sees it back to CI to guess again; + the whole value of this test is that it can say which element and which + field, in a place no debugger reaches. + """ + left_tag, right_tag = plcopen.tag(left), plcopen.tag(right) + if left_tag != right_tag: + return "%s tag %r vs %r" % (path, left_tag, right_tag) + if left.text != right.text: + return "%s<%s> text %r vs %r" % (path, left_tag, left.text, right.text) + left_children, right_children = list(left), list(right) + if len(left_children) != len(right_children): + return "%s<%s> child count %d vs %d (%r vs %r)" % ( + path, + left_tag, + len(left_children), + len(right_children), + [plcopen.tag(c) for c in left_children][:6], + [plcopen.tag(c) for c in right_children][:6], + ) + for index in range(len(left_children)): + child_path = "%s%s[%d]/" % (path, plcopen.tag(left_children[index]), index) + found = first_difference(left_children[index], right_children[index], child_path) + if found: + return found + return None + + +def attribute_values(elem, names): + return [elem.get(name) for name in names] + + +if len(xmlbackend.available()) < 2: + # Not a pass. The comparison below is the whole point of this file, and it + # cannot run here. + print("") + print("SKIPPED the backend comparison: only %s is available on this host." % xmlbackend.active()) + print(" It runs in the IronPython CI job, where System.Xml exists.") + print("") +else: + for path in every_fixture(): + name = os.path.basename(path) + data = plcopen.read_document(path) + + one = xmlbackend.parse(data, xmlbackend.ELEMENT_TREE) + two = xmlbackend.parse(data, xmlbackend.SYSTEM_XML) + difference = first_difference(one, two) + check(name + ": both backends build the same tree", difference is None, difference or "") + + # Shape equality would not catch attributes, which is where most of + # the parsing decisions actually live. + interesting = ("localId", "refLocalId", "formalParameter", "negated", "typeName", "name", "edge", "storage") + left = [attribute_values(e, interesting) for e in one.iter()] + right = [attribute_values(e, interesting) for e in two.iter()] + check(name + ": both backends read the same attributes", left == right) + + # The contract that actually matters: identical rendered output. + import fbd_render # noqa: E402 + import ld_render # noqa: E402 + import parse_fbd # noqa: E402 + import parse_ld # noqa: E402 + + for path in every_fixture(): + name = os.path.basename(path) + rendered = {} + for backend in (xmlbackend.ELEMENT_TREE, xmlbackend.SYSTEM_XML): + previous = xmlbackend.use(backend) + try: + lines = [] + for pou in parse_ld.parse_pous(path): + lines.extend(ld_render.render_pou(pou)) + for pou in parse_fbd.parse_pous(path): + lines.extend(fbd_render.render_pou(pou)) + rendered[backend] = lines + finally: + xmlbackend.use(previous) + check(name + ": both backends render identically", rendered[xmlbackend.ELEMENT_TREE] == rendered[xmlbackend.SYSTEM_XML]) + +print("") +if failures: + print("%d check(s) failed" % len(failures)) +else: + print("all checks passed") +sys.exit(1 if failures else 0)