diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c1f7374 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Without this file, line endings are decided by each developer's +# core.autocrlf. The same commit then checks out differently on different +# machines, and whoever has it set the other way sees every exported file +# rewritten the next time they export - a diff of the whole tree that says +# nothing about what changed. +* text=auto + +# CODESYS writes CRLF, and so does the export. These files are compared +# against what CODESYS writes and imported back into it, so they check out +# CRLF everywhere, whatever git is configured to do locally. Only the working +# tree is pinned; git still stores them normalised, so diffs stay line by +# line rather than whole-file. +GraphicalTesting/** text eol=crlf +tools/ladder/tests/fixtures/codesys/** text eol=crlf + +# A .project is a zip. Converting one would corrupt it. +*.project binary 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..ac31672 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,40 @@ +# CODESYS working files. None of these belong in the repository: +# a .project is a large binary that every clone then carries for ever, and it +# and the .opt session files carry device identifiers - serial numbers and +# gateway addresses - along with the user and machine name in the .~u lock. +# The template and the exported sources together regenerate the project. +*.project +*.opt +*.~u +# The template is the exception: it and the exported sources together +# regenerate the working project, so it is tracked. Its connection label has +# had the controller address and serial taken out of it - see the commit that +# tracked it for why clearing the gateway in CODESYS is not enough. +!GraphicalTesting_template_v1.project + +# Review correspondence. Kept beside the checkout, not in it. +review/ + +# The export's device and fieldbus xml. 449,000 lines of CR711S and CAN bus +# descriptions that say nothing about the code under review, and 36 MB that +# every clone would carry for ever. The application folders are what is +# tracked; a full export still writes these, they are just not committed. +GraphicalTesting/*/devices/ +GraphicalTesting/*/communication/ + +# The staging and backup copies util.py writes beside an export. A locked +# target leaves one behind, and a stray "git add -A" would sweep the whole +# export into the repo under it - which is how the device xml got committed +# once already. +*.codescribe_staging/ +*.codescribe_backup/ + .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] @@ -160,3 +195,22 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# The exported source tree is tracked in full, so none of the generic Python +# and tooling patterns above may reach into it: a POU or a folder a developer +# names "lib", "build", "var" or "target" would otherwise be dropped from the +# export with no warning. Re-include the directories first and then their +# files - git will not descend into a directory a bare pattern excluded - and +# re-exclude only the two heavy subtrees that are deliberately left out. +!GraphicalTesting/**/ +!GraphicalTesting/** +GraphicalTesting/*/devices/ +GraphicalTesting/*/communication/ +# The re-include above is the last pattern to match inside the export, so it +# cancels the CODESYS working-file rules at the top of this file as well. Those +# carry the controller serial and gateway address; exclude them again here. +GraphicalTesting/**/*.project +GraphicalTesting/**/*.opt +GraphicalTesting/**/*.~u +GraphicalTesting/**/*.codescribe_staging/ +GraphicalTesting/**/*.codescribe_backup/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e3c4f..7709e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,14 @@ ## Unreleased +- Ladder and Function Block Diagram POUs export a derived `.txt` beside their native xml, holding the declaration and a diagram of each network. Graphical POUs have no textual implementation, so the native xml was the only thing tracked and no change to one could be reviewed in a diff. The declaration is copied from the CODESYS declaration source, keeping comments, pragmas, safety-qualified types and literal spelling; the diagram is derived from a PLCopen export, and its layout comes from how the elements are wired rather than from their coordinates, so moving a block in the editor produces no diff. Networks are numbered as the editor numbers them, read from the native xml beside the rendering: an out-commented or empty network keeps its number and says why it has no diagram, instead of being dropped and renumbering every network after it. A graphical action, transition or method is rendered from its own body, not from the parent POU that its PLCopen export wraps it in. The file is derived and read-only - `Import From Files` dispatches on `.xml` and `.st`, so a `.txt` is ignored by construction and the round trip is unaffected. SFC and CFC POUs are not yet rendered and export as native xml alone. An EXECUTE box shows the inline ST that is its whole body inside the box, below its pins, rather than as an empty rectangle. Where the export carries no plaintext declaration the rendering is rebuilt from the structured interface and says so on its first line, and a variable whose type the export omits reads `UNKNOWN` rather than being assumed to be a `BOOL`. The export summary reports how many POUs were rendered and which were skipped for having no renderable body; a POU whose rendering raises is reported on its own warning line as the export carries on. An equivalent-Structured-Text rendering of the same networks is available from `tools/ladder/write_st.py`, but the export does not write one. +- An export that produces no files no longer replaces the previous export folder with an empty one; it fails with an explanation and leaves the folder untouched. `Export Lib To Files` on a device project was the trigger: its walker only exports objects directly under the project root, so on a device project it exported nothing and wiped the existing export. That script now also refuses a project with devices up front and points to `Export To Files`. +- The Library Manager exports its reference list as a read-only `.libraries.txt` (name, version, vendor per line). Library behaviour itself is not exportable, but any bench check of a library needs the version it resolves to - shown where CODESYS reports it, and as the requested constraint (such as `*` for newest) where it does not. +- The Visualization Manager exports natively as a read-only `.service.txt`. It is exported recursively to take in the target and web visualization settings under it; the global hotkey (key configuration) mapping is in the manager entry itself. Both become reviewable. It is still never imported - importing it raises interactive overwrite dialogs, which is why earlier versions dropped it from the export entirely - and the project template continues to carry the real object. + - Visualisations export as `.vis.xml`. Earlier versions wrote `.xml`, which silently collided with any POU of the same name (a `Main` program plus a `Main` visualisation is common). Old plain `.xml` exports still import correctly; re-exporting once migrates the tracked files. - Visualisation service objects (visualisation manager and related service GUIDs) are recognised and no longer exported. -- `Export To Files` writes the export into a sibling folder named `.codescribe_staging` and only swaps it into place once the export completes. Earlier versions deleted the target folder up front, so a locked folder (an open Explorer window, IDE, git client or antivirus) aborted the export immediately and a mid-export crash left the on-disk copy destroyed. If the target folder is locked and cannot be swapped, the staged files are synced into it instead and the export still succeeds; if that also fails, the error dialog reports the staging folder path, so the completed export is preserved. +- `Export To Files` writes the export into a sibling folder named `.codescribe_staging` and only swaps it into place once the export completes. Earlier versions deleted the target folder up front, so a locked folder (an open Explorer window, IDE, git client or antivirus) aborted the export immediately and a mid-export crash left the on-disk copy destroyed. If the target folder is locked and cannot be swapped, the staged files are synced into it instead and the export still succeeds; if that also fails, the error dialog reports the staging folder path, so the completed export is preserved. Syncing also removes files the export did not write, as the swap would have, so a rendering from a previous export is never left beside a newer native xml; any that cannot be removed are named. - Device-tree sibling devices (Ethernet, Modbus and fieldbus devices that sit next to `Plc Logic` as direct children of the PLC device) are exported to `/devices/.xml`, one file per top-level device. On import, each tracked device is removed and recreated from its exported xml; devices fixed by the device package (for example `Local_IO` and `HMI` on IFM hardware) cannot be removed and are skipped with a message, with their configuration carried by the project template. A `_NO_EXPORT` folder added as a direct child of the PLC device disables the device-tree export. - New `Export Lib To Files` script exports library projects, which keep their objects directly under the project root rather than under a Device. It writes the same on-disk format as `Export To Files`, without the device and application folder levels. Importing a library export back into a project is not yet supported. diff --git a/GraphicalTesting/SafetyPLC/application/EVL.gvl.st b/GraphicalTesting/SafetyPLC/application/EVL.gvl.st new file mode 100644 index 0000000..3196153 --- /dev/null +++ b/GraphicalTesting/SafetyPLC/application/EVL.gvl.st @@ -0,0 +1,7 @@ +(* THIS EVL IS AUTOGENERATED. DO NOT MODIFY THESE ATTRIBUTES. *) +{attribute 'location' := '16#800' } +{attribute 'linkalways'} +{attribute 'qualified_only'} +VAR_GLOBAL + +END_VAR diff --git a/GraphicalTesting/SafetyPLC/application/EVL.gvl.xml b/GraphicalTesting/SafetyPLC/application/EVL.gvl.xml new file mode 100644 index 0000000..0258b8e --- /dev/null +++ b/GraphicalTesting/SafetyPLC/application/EVL.gvl.xml @@ -0,0 +1,90 @@ + + + + + + + True + + c92bf834-0a70-4e8b-9367-2d81823c2f23 + 4403a37e-f528-4863-8b5f-3c6def9b45a5 + EVL + + + + 0fc3aa33-c9bf-436a-b5e4-fcd0ccb10d58 + + + + + + + ffbfa93a-b94d-45fc-a329-229860183b1d + + a9ed5b7e-75c5-4651-af16-d2c27e98cb94 + + 0 + + + + + + + 59 + + (* THIS EVL IS AUTOGENERATED. DO NOT MODIFY THESE ATTRIBUTES. *) + + + 60 + + {attribute 'location' := '16#800' } + + + 61 + + {attribute 'linkalways'} + + + 62 + + {attribute 'qualified_only'} + + + 63 + + VAR_GLOBAL + + + 64 + + + + + 65 + + END_VAR + + + 1 + + + + + + + + False + False + + 4403a37e-f528-4863-8b5f-3c6def9b45a5 + + SafetyPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/SafetyPLC/application/Library Manager.libraries.txt b/GraphicalTesting/SafetyPLC/application/Library Manager.libraries.txt new file mode 100644 index 0000000..4167299 --- /dev/null +++ b/GraphicalTesting/SafetyPLC/application/Library Manager.libraries.txt @@ -0,0 +1,5 @@ +(* Library references - regenerated by Export To Files; read-only, never imported *) +ifmR360-3, * (ifm electronic) +#IoStandard -> IoStandard, 3.5.10.0 (System) +#CmpSIL2 -> CmpSIL2, 3.5.7.0 (System) +#3SLicense -> 3SLicense, 3.5.10.0 (3S - Smart Software Solutions GmbH) diff --git a/GraphicalTesting/SafetyPLC/application/PLC_PRG.st b/GraphicalTesting/SafetyPLC/application/PLC_PRG.st new file mode 100644 index 0000000..194f411 --- /dev/null +++ b/GraphicalTesting/SafetyPLC/application/PLC_PRG.st @@ -0,0 +1,8 @@ +PROGRAM PLC_PRG +VAR + +END_VAR + +// --- BEGIN IMPLEMENTATION --- + + diff --git a/GraphicalTesting/SafetyPLC/application/TaskConfiguration.xml b/GraphicalTesting/SafetyPLC/application/TaskConfiguration.xml new file mode 100644 index 0000000..969bb81 --- /dev/null +++ b/GraphicalTesting/SafetyPLC/application/TaskConfiguration.xml @@ -0,0 +1,112 @@ + + + + + + + True + + 8fcf8f4e-747e-4afc-bebd-2ebdf08d5272 + 4403a37e-f528-4863-8b5f-3c6def9b45a5 + TaskConfiguration + + ae1de277-a207-4a28-9efb-456c06bd52f3 + + 0 + + + + + + 43f23742-dece-5e41-901c-02c6552b03dc + 9f564949-54f6-5de0-a5ad-d6e23224445c + 032a6610-5901-5920-9c37-9d23b19bc3b6 + f93e51af-18d4-5273-b6f9-b20a2445edb2 + cc9aa7ec-7090-59b2-bbe8-58036d812d0f + d9e6755d-f94b-563f-b03c-4806c2d88733 + dea6d353-a39e-53b0-98c9-31e44b306387 + 70fb98b7-5143-57e8-8073-b23d95fa3c97 + b4ea44ff-6309-5f81-9900-86ea43c12e41 + 3e4f103e-b93b-5f1f-bb80-011c5864de82 + 07f9199e-3544-5624-ba50-17c19c45fb08 + 76078381-55d8-5270-b3a5-8af547761d98 + 054c9e67-a8c2-5f23-bbd7-b1602bf93bba + 34ff86c0-633c-5389-9a16-a22363759955 + d775bf58-982d-5bfa-968e-db4dc501f3aa + + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + + 00000000-0000-0000-0000-000000000000 + + 4403a37e-f528-4863-8b5f-3c6def9b45a5 + + SafetyPLC + PLC Logic + Application + + -1 + + + False + + e2894a44-2e4c-477e-9bf1-3cc1b1eae293 + 8fcf8f4e-747e-4afc-bebd-2ebdf08d5272 + Task + + 98a2708a-9b18-4f31-82ed-a1465b24fa2d + + 0 + + + Cyclic + + + + + + 1 + + True + + t#8ms + ms + + 1 + + + t#10ms + + + + + b041315d-8268-483e-b4be-e89bb85eddf8 + c177d10e-0864-40cc-bb92-d152e1fd7768 + + + PLC_PRG + + + + False + True + -2 + + + 8fcf8f4e-747e-4afc-bebd-2ebdf08d5272 + + SafetyPLC + PLC Logic + Application + Task Configuration + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/EVC.xml b/GraphicalTesting/StandardPLC/application/EVC.xml new file mode 100644 index 0000000..67eb5d7 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/EVC.xml @@ -0,0 +1,32 @@ + + + + + + + True + + 3c56bead-196b-477a-b2ce-c9db55772c1a + f9c00bdb-2f18-4e80-863e-6da977e8f304 + EVC + + 327b6465-4e7f-4116-846a-8369c730fd66 + + 0 + + + + + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.txt new file mode 100644 index 0000000..f6bffda --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.txt @@ -0,0 +1,25 @@ +(* FB_TESTING.action_test - the declaration below is the parent POU's *) + +PROGRAM FB_TESTING +(*********************************************************************************************** +FULLY SICK FUNCTION BLOCK DIAGRAM FOR TESTING. Do not worry about what the logic is doing. +***********************************************************************************************) +VAR CONSTANT + uiMinVoltage : UINT := 5000; // Minimum Voltage in mV +END_VAR +VAR + fbSystemSupply : ifmIOcommon.SystemSupply; // Function Block to monitor supply voltage on VBB15 (from ignition) + fbSupplySwitch : ifmIOcommon.SupplySwitch; // Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) + uiCurrSupplyVolt : UINT ; // Operating Voltage in mV + TOF_0: TOF; + Test: BOOL; + dude: BOOL; + fbenable: TON; + xResult : BOOL; + xTimerDone : BOOL; + TON_0: TON; + whereismycar : BOOL; +END_VAR + +(* Network 1 *) +dude───> TRUE diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.xml b/GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.xml new file mode 100644 index 0000000..0823f17 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.action_test.xml @@ -0,0 +1,103 @@ + + + + + + + True + + 13dcd058-9705-4825-ae9f-af8738989171 + cedc2742-8922-46db-927d-5f652c9943c9 + action_test + + 8ac092e5-3128-4e26-9e7e-11016c6684f2 + + 25e509de-33d4-4447-93f8-c9e4ea381c8b + + 0 + + + + + Fbd + + + False + False + False + + + + + False + + + + + + TRUE + BOOL + + + + + 0 + False + False + + True + False + False + 4 + + + + + 0 + False + False + + + + dude + BOOL + + + + + 0 + False + False + + False + False + False + 3 + + 2 + + 5 + + + + 1 + + + 0 + True + + 5 + + cedc2742-8922-46db-927d-5f652c9943c9 + + StandardPLC + PLC Logic + Application + FB_TESTING + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.txt b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt new file mode 100644 index 0000000..6fc9dc7 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.txt @@ -0,0 +1,81 @@ +PROGRAM FB_TESTING +(*********************************************************************************************** +FULLY SICK FUNCTION BLOCK DIAGRAM FOR TESTING. Do not worry about what the logic is doing. +***********************************************************************************************) +VAR CONSTANT + uiMinVoltage : UINT := 5000; // Minimum Voltage in mV +END_VAR +VAR + fbSystemSupply : ifmIOcommon.SystemSupply; // Function Block to monitor supply voltage on VBB15 (from ignition) + fbSupplySwitch : ifmIOcommon.SupplySwitch; // Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) + uiCurrSupplyVolt : UINT ; // Operating Voltage in mV + TOF_0: TOF; + Test: BOOL; + dude: BOOL; + fbenable: TON; + xResult : BOOL; + xTimerDone : BOOL; + TON_0: TON; + whereismycar : BOOL; +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│ + └───────────┘ + +(* Network 3 *) +ByeBye: +(* empty network *) + +(* Network 4 *) +(* empty network *) + +(* Network 5: Comment only network *) +(* empty network *) + +(* Network 6: Comment only network with comment slash *) +(* empty network *) + +(* Network 7: (* Comment only network with brackets * ) *) +(* empty network *) + +(* Network 8 *) +Test───>> ByeBye + +(* Network 9: comment for the sakes of comments *) + fbenable : TON + ┌──────────────┐ +Test──┤EN ENO├─────────────────┬────> xResult +Test──┤IN Q├───> xTimerDone │ OR AND +T#5s──┤PT ET│ │ ┌──────────┐ ┌──────────┐ + └──────────────┘ └──────┤In1 Out1├──┤In1 Out1├───> Test + dude──┤In2 │ │ │ + └──────────┘ │ │ + xTimerDone──────────┤In2 │ + └──────────┘ + +(* Network 10 *) + EXECUTE + ┌───────────────────────────┐ +TRUE──┤EN ENO├───> dude + │ IF dude THEN │ + │ whereismycar := TRUE; │ + │ END_IF │ + └───────────────────────────┘ diff --git a/GraphicalTesting/StandardPLC/application/FB_TESTING.xml b/GraphicalTesting/StandardPLC/application/FB_TESTING.xml new file mode 100644 index 0000000..f5c86c8 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/FB_TESTING.xml @@ -0,0 +1,1291 @@ + + + + + + + True + + cedc2742-8922-46db-927d-5f652c9943c9 + f9c00bdb-2f18-4e80-863e-6da977e8f304 + FB_TESTING + + 6f9dac99-8de1-4efc-8465-68ac443b7d08 + + a9ed5b7e-75c5-4651-af16-d2c27e98cb94 + 25e509de-33d4-4447-93f8-c9e4ea381c8b + + 0 + + + None + + + Fbd + + + False + False + False + + // Function Block to monitor supply voltage on VBB15 (from ignition) + + + False + + + ifmIOcommon.SystemSupply + + fbSystemSupply + ifmIOcommon.SystemSupply + + + + + 0 + False + False + + False + False + True + 22 + + + + + + + + + + + + 0 + False + False + + True + False + False + 24 + + + + + + + + + 0 + False + False + + True + False + False + 25 + + + uiCurrSupplyVolt + UINT + + + + + 0 + False + False + + True + False + False + 26 + + + + + 0 + True + False + + + + + + ifmIOcommon.SYS_VOLTAGE_CHANNEL.VBB15 + SYS_VOLTAGE_CHANNEL + + + + + 0 + False + False + + False + False + False + 19 + + 18 + + + + ifmIOcommon.MODE_SYSTEM_SUPPLY.SYS_SUPPLY + MODE_SYSTEM_SUPPLY + + + + + 0 + False + False + + False + False + False + 21 + + 20 + + + + + + + + + + 0 + False + False + + False + False + False + 28 + + 27 + + + + + eChannel + eMode + eFilter + + + SYS_VOLTAGE_CHANNEL + MODE_SYSTEM_SUPPLY + FILTER_INPUT + + + + + xError + eDiagInfo + xPrepared + uiOutVoltage + + + BOOL + ifmTypes.DIAG_INFO + BOOL + UINT + + + FunctionBlock + False + False + + False + False + 23 + + + + 5 + + + False + False + False + + //Safely power off PLC when ignition (VBB15) is lower than 5V for more than 10s. Enable timer on falling edge + + + False + + + ifmIOcommon.SupplySwitch + + fbSupplySwitch + ifmIOcommon.SupplySwitch + + + + + 0 + False + False + + False + False + True + 105 + + + + + + + + + + + + 0 + False + False + + True + False + False + 107 + + + + + + + + + 0 + False + False + + True + False + False + 108 + + + + + 0 + True + False + + + + + + ifmIOcommon.MODE_SUPPLY_SWITCH.SYS_SUPPLY_SWITCH + MODE_SUPPLY_SWITCH + + + + + 0 + False + False + + False + False + False + 96 + + 95 + + + TOF + + TOF_0 + TOF + + + + + 0 + False + False + + False + False + True + 124 + + + + + + + + + + + + 0 + False + False + + True + False + False + 115 + + + + + 0 + True + False + + + + + GT + + + + + + + + 0 + False + False + + False + False + True + 126 + + + + + + + + 0 + True + False + + + + + + uiCurrSupplyVolt + UINT + + + + + 0 + False + False + + False + False + False + 119 + + 118 + + + + uiMinVoltage + UINT + + + + + 0 + False + False + + False + False + False + 121 + + 120 + + + + + + + + + + + + BOOL + + + Gt + False + False + + False + False + 127 + + + + T#5S + TIME + + + + + 0 + False + False + + False + False + False + 112 + + 111 + + + + + IN + PT + + + BOOL + TIME + + + + + Q + ET + + + BOOL + TIME + + + FunctionBlock + False + False + + False + False + 125 + + + + + eMode + xValue + + + MODE_SUPPLY_SWITCH + BOOL + + + + + xError + eDiagInfo + xPrepared + + + BOOL + ifmTypes.DIAG_INFO + BOOL + + + FunctionBlock + False + False + + False + False + 106 + + + + 92 + + + False + False + False + + + + ByeBye + False + + + 142 + + + False + False + False + + + + + False + + + 132 + + + False + False + False + + Comment only network + + + False + + + 133 + + + False + False + False + + // Comment only network with comment slash + + + False + + + 134 + + + False + False + False + + (* Comment only network with brackets *) + + + False + + + 135 + + + False + False + False + + + + + False + + + + + + ByeBye + BOOL + + + + + 4 + False + False + + True + False + False + 139 + + + + + 0 + False + False + + + + Test + BOOL + + + + + 0 + False + False + + False + False + False + 138 + + 137 + + 140 + + + + 136 + + + False + False + False + + comment for the sakes of comments + + + False + + + 6 + + TON + + fbenable + TON + + + + + 0 + False + False + + False + False + True + 162 + + + + + + xTimerDone + BOOL + + + + + 0 + False + False + + True + False + False + 153 + + + + + + + + + 0 + False + False + + True + False + False + 164 + + + + + 0 + True + False + + + + + + Test + BOOL + + + + + 0 + False + False + + False + False + False + 155 + + 154 + + + + Test + BOOL + + + + + 0 + False + False + + False + False + False + 157 + + 156 + + + + T#5s + TIME + + + + + 0 + False + False + + False + False + False + 159 + + 158 + + + + + EN + IN + PT + + + BOOL + BOOL + TIME + + + + + ENO + Q + ET + + + BOOL + BOOL + TIME + + + FunctionBlock + True + True + + False + False + 163 + + 166 + + + + + + xResult + BOOL + + + + + 0 + False + False + + True + False + False + 175 + + + + + 0 + False + False + + + 6 + + 167 + + 176 + + + + + + Test + BOOL + + + + + 0 + False + False + + True + False + False + 185 + + + + + 0 + False + False + + + AND + + + + + + + + 0 + False + False + + False + False + True + 183 + + + + + + + + 0 + True + False + + + + + OR + + + + + + + + 0 + False + False + + False + False + True + 173 + + + + + + + + 0 + True + False + + + + + 6 + + 168 + + + + dude + BOOL + + + + + 0 + False + False + + False + False + False + 172 + + 171 + + + + + + + + + + + + BOOL + + + Or + False + False + + False + False + 174 + + + + xTimerDone + BOOL + + + + + 0 + False + False + + False + False + False + 182 + + 181 + + + + + + + + + + + + BOOL + + + And + False + False + + False + False + 184 + + 186 + + + + 150 + + + False + False + False + + + + + False + + + + + + dude + BOOL + + + + + 0 + False + False + + True + False + False + 222 + + + + + 0 + False + False + + + EXECUTE + + + + + + + + 0 + False + False + + False + False + True + 229 + + + + + + + + 0 + True + False + + + + + + TRUE + BOOL + + + + + 0 + False + False + + False + False + False + 221 + + 220 + + + + + EN + + + BOOL + + + + + ENO + + + BOOL + + + None + True + True + + + + + + 230 + + IF dude THEN + + + 231 + + whereismycar := TRUE; + + + 226 + + END_IF + + + + + 232 + + False + True + 233 + + 223 + + + + 215 + + + 7 + True + + + + + + 2 + + PROGRAM FB_TESTING + + + 7 + + (*********************************************************************************************** + + + 12 + + FULLY SICK FUNCTION BLOCK DIAGRAM FOR TESTING. Do not worry about what the logic is doing. + + + 6 + + ***********************************************************************************************) + + + 46 + + VAR CONSTANT + + + 47 + + uiMinVoltage : UINT := 5000; // Minimum Voltage in mV + + + 48 + + END_VAR + + + 3 + + VAR + + + 13 + + fbSystemSupply : ifmIOcommon.SystemSupply; // Function Block to monitor supply voltage on VBB15 (from ignition) + + + 14 + + fbSupplySwitch : ifmIOcommon.SupplySwitch; // Function Block to turn ON/OFF latching switch on VBB15 supply (from ignition) + + + 131 + + uiCurrSupplyVolt : UINT ; // Operating Voltage in mV + + + 102 + + TOF_0: TOF; + + + 141 + + Test: BOOL; + + + 149 + + dude: BOOL; + + + 165 + + fbenable: TON; + + + 177 + + xResult : BOOL; + + + 178 + + xTimerDone : BOOL; + + + 214 + + TON_0: TON; + + + 234 + + whereismycar : BOOL; + + + 4 + + END_VAR + + + 71 + + + + + + + 234 + Standard + + False + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.txt b/GraphicalTesting/StandardPLC/application/LD_TEST.txt new file mode 100644 index 0000000..c3482a7 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.txt @@ -0,0 +1,57 @@ +PROGRAM LD_TEST +VAR + Sensor1: BOOL; + Sensor2: BOOL; + sensor3: BOOL; + PowerOn: BOOL; + TON_0: TON; + CTU_0: CTU; + PowerOff: BOOL; + edgetrigger: BOOL; + answer: UINT; + BLINK_0: BLINK; +END_VAR + +(* Network 1: Try me Codesys I swear *) +(* comment without backslash *) +│ Sensor1 Sensor2 PowerOn +├──┬───┤ ├───┬───┤/├──────(S)─────┤ +│ │ sensor3 │ +│ └───┤ ├───┘ + +(* Network 2: That'll do pig, that'll do. (Babe) *) +│ +├────>>TestJump────┤ + +(* Network 3: header text *) +(* Comment *) +│ PowerOff PowerOff PowerOff +├──┬───┤ ├──────────────────────────────────────────────────────────────┬───┤/├───────(R)──────┤ +│ │ TON_0 : TON CTU_0 : CTU │ +│ │ PowerOn ┌───────────┐ ┌───────────┐ PowerOn │ +│ ├───┤ ├──────────┤IN Q├────────────────┤CU Q├────┤ ├───┤ +│ │ T#5S──┤PT ET│ PowerOff ┤P├──PRESET CV│ │ +│ │ └───────────┘ 10────────────┤PV │ │ +│ │ └───────────┘ │ +│ │ PowerOn │ +│ └───┤ ├──────────────────────────────────────────────────────────────┘ + +(* Network 4: (Babe in the big City) *) +TestJump: +(* empty network *) + +(* Network 5 *) +│ ADD +│ PowerOff PowerOn ┌──────────┐ +├─────┤/├───────┤ ├───────┤EN ENO├────────────┬─────────────────┤ +│ 1──┤In2 Out2├───> answer │ edgetrigger PowerOff +│ 2──┤In3 │ └─────┤P├────────( )──────┤ +│ └──────────┘ + +(* Network 6 *) +│ BLINK_0 : BLINK +│ edgetrigger ┌───────────────┐ PowerOn +├───────┤ ├───────────────┤ENABLE OUT├────( )─────┤ +│ T#500ms──┤TIMELOW │ +│ T#500ms──┤TIMEHIGH │ +│ └───────────────┘ diff --git a/GraphicalTesting/StandardPLC/application/LD_TEST.xml b/GraphicalTesting/StandardPLC/application/LD_TEST.xml new file mode 100644 index 0000000..1047b63 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/LD_TEST.xml @@ -0,0 +1,1356 @@ + + + + + + + True + + 4f4ad042-bbb9-4292-adb4-f91543e47fce + f9c00bdb-2f18-4e80-863e-6da977e8f304 + LD_TEST + + 6f9dac99-8de1-4efc-8465-68ac443b7d08 + + a9ed5b7e-75c5-4651-af16-d2c27e98cb94 + 25e509de-33d4-4447-93f8-c9e4ea381c8b + + 0 + + + None + + + Ld + + + False + False + False + + comment without backslash + Try me Codesys I swear + + False + + + + + + PowerOn + BOOL + + + + + 2 + True + False + + True + True + False + 22 + + + + + 0 + False + False + + + AND + + + + + + + + 0 + False + False + + False + False + True + 9 + + + + + + 0 + False + False + + + + + OR + + + + + + + + 0 + False + False + + False + False + True + 17 + + + + + + 0 + False + False + + + + + + Sensor1 + BOOL + + + + + 0 + True + False + + False + True + False + 7 + + 6 + + + + sensor3 + BOOL + + + + + 0 + True + False + + False + True + False + 20 + + 19 + + + + + + + + + + + Or + + + + False + False + 18 + + + + Sensor2 + BOOL + + + + + 1 + True + False + + False + True + False + 12 + + 11 + + + + + + + + + + + And + + + + False + False + 10 + + 23 + + + + 5 + + + False + False + False + + That'll do pig, that'll do. (Babe) + + + False + + + + + + TestJump + + + + + + 4 + False + False + + True + False + False + 52 + + + + + 0 + False + False + + + + + 0 + False + False + + 51 + + 53 + + + + 50 + + + False + False + False + + // Comment + header text + + False + + + + + + PowerOff + BOOL + + + + + 3 + True + False + + True + True + False + 34 + + + + + 0 + False + False + + + AND + + + + + + + + 0 + False + False + + False + False + True + 126 + + + + + + 0 + False + False + + + + + OR + + + + + + + + 0 + False + False + + False + False + True + 122 + + + + + + 0 + False + False + + + + + + PowerOff + BOOL + + + + + 0 + True + False + + False + True + False + 125 + + 124 + + + AND + + + + + + + + 0 + False + False + + False + False + True + 114 + + + + + + 0 + False + False + + + + + CTU + + CTU_0 + CTU + + + + + 0 + False + False + + False + False + True + 36 + + + + + + + WORD + + + + + 0 + False + False + + True + False + False + 38 + + + + + 0 + False + False + + + + + TON + + TON_0 + TON + + + + + 0 + False + False + + False + False + True + 26 + + + + + + + TIME + + + + + 0 + False + False + + True + False + False + 28 + + + + + 0 + False + False + + + + + + PowerOn + BOOL + + + + + 0 + False + False + + False + True + False + 30 + + 29 + + + + T#5S + TIME + + + + + 0 + False + False + + False + False + False + 32 + + 31 + + + + + IN + PT + + + BOOL + TIME + + + + + Q + ET + + + BOOL + TIME + + + FunctionBlock + False + False + + False + False + 27 + + + + PowerOff + BOOL + + + + + 16 + True + False + + False + True + False + 40 + + 39 + + + + 10 + INT + + + + + 0 + False + False + + False + False + False + 42 + + 41 + + + + + CU + RESET + PV + + + BOOL + BOOL + WORD + + + + + Q + CV + + + BOOL + WORD + + + FunctionBlock + False + False + + False + False + 37 + + + + PowerOn + BOOL + + + + + 0 + True + False + + False + True + False + 117 + + 116 + + + + + + + + + + + And + + + + False + False + 115 + + + + PowerOn + BOOL + + + + + 0 + True + False + + False + True + False + 121 + + 120 + + + + + + + + + + + Or + + + + False + False + 123 + + + + PowerOff + BOOL + + + + + 1 + True + False + + False + True + False + 129 + + 128 + + + + + + + + + + + And + + + + False + False + 127 + + 35 + + + + 25 + + + False + False + False + + (Babe in the big City) + + TestJump + False + + + 49 + + + False + False + False + + + + + False + + + 13 + + ADD + + + + + + + + 0 + False + False + + False + False + True + 111 + + + + + + answer + UINT + + + + + 0 + False + False + + True + False + False + 57 + + + + + 0 + True + False + + + + + AND + + + + + + + + 0 + False + False + + False + False + True + 64 + + + + + + 0 + False + False + + + + + + PowerOff + BOOL + + + + + 1 + True + False + + False + True + False + 67 + + 66 + + + + PowerOn + BOOL + + + + + 0 + False + False + + False + False + False + 59 + + 58 + + + + + + + + + + + And + + + + False + False + 65 + + + + 1 + INT + + + + + 0 + False + False + + False + False + False + 61 + + 60 + + + + 2 + INT + + + + + 0 + False + False + + False + False + False + 63 + + 62 + + + + + EN + + + BOOL + + + + + ENO + + + + BOOL + INT + + + Add + True + True + + False + False + 112 + + 72 + + + + + + ??? + BOOL + + + + + 8 + False + False + + True + False + False + 70 + + + + + 0 + False + False + + + 13 + + 73 + + 71 + + + + + + PowerOff + BOOL + + + + + 0 + False + False + + True + False + False + 81 + + + + + 0 + False + False + + + AND + + + + + + + + 0 + False + False + + False + False + True + 75 + + + + + + 0 + False + False + + + + + 13 + + 74 + + + + edgetrigger + BOOL + + + + + 16 + True + False + + False + True + False + 78 + + 77 + + + + + + + + + + + And + + + + False + False + 76 + + 82 + + + + 54 + + + False + False + False + + + + + False + + + + + + PowerOn + BOOL + + + + + 0 + False + False + + True + True + False + 142 + + + + + 0 + False + False + + + BLINK + + BLINK_0 + BLINK + + + + + 0 + False + False + + False + False + True + 137 + + + + + + + + 0 + True + False + + + + + + edgetrigger + BOOL + + + + + 0 + False + False + + False + False + False + 134 + + 133 + + + + T#500ms + TIME + + + + + 0 + False + False + + False + False + False + 136 + + 135 + + + + T#500ms + TIME + + + + + 0 + False + False + + False + False + False + 140 + + 139 + + + + + ENABLE + TIMELOW + TIMEHIGH + + + BOOL + TIME + TIME + + + + + OUT + + + BOOL + + + FunctionBlock + False + False + + False + False + 138 + + 143 + + + + 130 + + + 14 + True + + + + + + 2 + + PROGRAM LD_TEST + + + 3 + + VAR + + + 8 + + Sensor1: BOOL; + + + 13 + + Sensor2: BOOL; + + + 21 + + sensor3: BOOL; + + + 24 + + PowerOn: BOOL; + + + 33 + + TON_0: TON; + + + 43 + + CTU_0: CTU; + + + 44 + + PowerOff: BOOL; + + + 80 + + edgetrigger: BOOL; + + + 113 + + answer: UINT; + + + 141 + + BLINK_0: BLINK; + + + 4 + + END_VAR + + + 1 + + + + + + + 143 + Standard + + False + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/Library Manager.libraries.txt b/GraphicalTesting/StandardPLC/application/Library Manager.libraries.txt new file mode 100644 index 0000000..3b2bdf9 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/Library Manager.libraries.txt @@ -0,0 +1,25 @@ +(* Library references - regenerated by Export To Files; read-only, never imported *) +ifmR360-3, 1.1.1.0 (ifm electronic) +#Util -> Util, 3.5.11.0 (System) +#Standard -> Standard, 3.5.9.0 (System) +CR711S IOWrapper, 1.0.4.9 (ifm electronic) +#IoStandard -> IoStandard, 3.5.10.0 (System) +#3SLicense -> 3SLicense, 3.5.10.0 (3S - Smart Software Solutions GmbH) +#IoDrvJ1939 -> IoDrvJ1939, 3.5.11.0 (3S - Smart Software Solutions GmbH) +#CANbusDevice -> CANbusDevice, 3.5.10.0 (3S - Smart Software Solutions GmbH) +#3S CANopenStack -> 3S CANopenStack, 3.5.11.0 (3S - Smart Software Solutions GmbH) +#CAA CiA405 -> CAA CiA 405, 3.5.8.0 (CAA Technical Workgroup) +#IecSfc -> IecSfc, 3.4.2.0 (System) +#Analyzation -> Analyzation, 3.5.11.0 (System) +#System_VisuElems -> VisuElems, 3.5.11.0 (System) +#System_VisuElemMeter -> VisuElemMeter, 3.5.10.0 (System) +#System_VisuElemsSpecialControls -> VisuElemsSpecialControls, 3.5.11.0 (System) +#System_VisuElemsWinControls -> VisuElemsWinControls, 3.5.11.20 (System) +#System_VisuElemTextEditor -> VisuElemTextEditor, 3.5.11.0 (System) +#System_VisuElemTrace -> VisuElemTrace, 3.5.11.20 (System) +#System_VisuNativeControl -> VisuNativeControl, 3.5.11.0 (System) +#System_VisuElemsAlarm -> VisuElemsAlarm, 3.5.11.0 (System) +#System_VisuElemCamDisplayer -> VisuElemCamDisplayer, 3.5.10.0 (System) +#System_VisuElem3DPath -> VisuElem3DPath, 3.5.10.0 (System) +#System_VisuElemsDateTime -> VisuElemsDateTime, 3.5.11.0 (System) +#system_visuinputs -> visuinputs, 3.5.10.0 (system) diff --git a/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.st b/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.st new file mode 100644 index 0000000..53abfd9 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.st @@ -0,0 +1,25 @@ +{attribute 'qualified_only'} +VAR_GLOBAL PERSISTENT RETAIN + +wEngineOnCount :WORD; //Count OF times Engine is turned ON +wGpsOnCount :WORD; //Count OF times GPS Activated +wAutoOnCount :WORD; //Count OF times Auto Harvest Activated +wFlightOnCount :WORD; //Count OF times Flight Converyor is turned ON +wTotalMaintCount :WORD; //Count of times Maintenance Mode Activated +wTotalFilterCount :WORD; //Count of Filter Bypass Alarms +wTotalPressCount :WORD; //Count of Pressure Alarms +wTotalTempCount :WORD; //Count OF Temperature Alarms +wTotalEstopCount :WORD; //Count of times E-Stop is Activated +wTotalStallCount :WORD; //Count of Machine Stalls +wTotalDerateCount :WORD; //Count of De-rate Events +wTotalVoltageCount :WORD; //Count of Voltage Alarms + +wHarvPlcVoltCount : WORD; // Total count of Trailer PLC Voltage Alarms +wHarvDispVoltCount : WORD; // Total count of Trailer Display Voltage Alarms +wTracDispVoltCount : WORD; // Total count of Prime Mover Display Voltage Alarms + +wNode20VoltCount : WORD; // Total count of IO module Node 20 Module Voltage Alarm +wNode0VoltCount : WORD; // Total count of Engine Node 50 Voltage Alarm + + +END_VAR diff --git a/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.xml b/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.xml new file mode 100644 index 0000000..067701b --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/PersistentVars.gvl.xml @@ -0,0 +1,173 @@ + + + + + + + True + + facaed1c-8cc9-4b97-81b6-7a7fcbbc7dc3 + f9c00bdb-2f18-4e80-863e-6da977e8f304 + PersistentVars + + 261bd6e6-249c-4232-bb6f-84c2fbeef430 + + a9ed5b7e-75c5-4651-af16-d2c27e98cb94 + + 0 + + + + + + + + 53 + + {attribute 'qualified_only'} + + + 54 + + VAR_GLOBAL PERSISTENT RETAIN + + + 55 + + + + + 56 + + wEngineOnCount :WORD; //Count OF times Engine is turned ON + + + 57 + + wGpsOnCount :WORD; //Count OF times GPS Activated + + + 58 + + wAutoOnCount :WORD; //Count OF times Auto Harvest Activated + + + 59 + + wFlightOnCount :WORD; //Count OF times Flight Converyor is turned ON + + + 60 + + wTotalMaintCount :WORD; //Count of times Maintenance Mode Activated + + + 61 + + wTotalFilterCount :WORD; //Count of Filter Bypass Alarms + + + 62 + + wTotalPressCount :WORD; //Count of Pressure Alarms + + + 63 + + wTotalTempCount :WORD; //Count OF Temperature Alarms + + + 64 + + wTotalEstopCount :WORD; //Count of times E-Stop is Activated + + + 65 + + wTotalStallCount :WORD; //Count of Machine Stalls + + + 66 + + wTotalDerateCount :WORD; //Count of De-rate Events + + + 67 + + wTotalVoltageCount :WORD; //Count of Voltage Alarms + + + 68 + + + + + 69 + + wHarvPlcVoltCount : WORD; // Total count of Trailer PLC Voltage Alarms + + + 70 + + wHarvDispVoltCount : WORD; // Total count of Trailer Display Voltage Alarms + + + 71 + + wTracDispVoltCount : WORD; // Total count of Prime Mover Display Voltage Alarms + + + 72 + + + + + 73 + + wNode20VoltCount : WORD; // Total count of IO module Node 20 Module Voltage Alarm + + + 74 + + wNode0VoltCount : WORD; // Total count of Engine Node 50 Voltage Alarm + + + 75 + + + + + 76 + + + + + 77 + + END_VAR + + + 1 + + + + + + + + 6b1b8188-8909-4566-85c8-3e49f9c6b06c + e359ed2f-25b4-4dea-b318-58fd4edee8c7 + 2 + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/SFC_TEST.xml b/GraphicalTesting/StandardPLC/application/SFC_TEST.xml new file mode 100644 index 0000000..dbd5c80 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/SFC_TEST.xml @@ -0,0 +1,1959 @@ + + + + + + + True + + d158652c-96fd-4ed4-aadb-28e74dfb74df + f9c00bdb-2f18-4e80-863e-6da977e8f304 + SFC_TEST + + + + 58548d2c-f1b1-453d-9195-66c94e2a3ae0 + + + + + + IecSfc + System + 3.4.2.0 + IecSfc + + + + CurrentStep + + + + False + True + + + + + + EnableLimit + + + + False + True + + + + + + Error + + + + False + True + + + + + + ErrorPOU + + + + False + True + + + + + + ErrorStep + + + + False + True + + + + + + Init + + + + False + True + + + + + + Pause + + + + False + True + + + + + + QuitError + + + + False + True + + + + + + Reset + + + + False + True + + + + + + Tip + + + + False + True + + + + + + TipMode + + + + False + True + + + + + + Trans + + + + False + True + + + + + + SFCErrorAnalyzation + + + + False + True + + + + + + SFCErrorAnalyzationTable + + + + False + True + + + + + False + True + + + + + 6f9dac99-8de1-4efc-8465-68ac443b7d08 + + a9ed5b7e-75c5-4651-af16-d2c27e98cb94 + 74f81948-1328-492c-b6c1-a9ea72048b28 + + 0 + + + None + + + + + + + + + + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + CmdBarriers + + + 285 + 284 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 286 + 284 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 287 + 284 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 288 + 284 + + + e174fc0d-80b0-4a9e-a530-ca239c249a50 + + R + + + 289 + 284 + + + 284 + 5 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + WaitingForTrain + + + 6 + 5 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 7 + 5 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 8 + 5 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 9 + 5 + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + TRUE + FALSE + + 10 + 5 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 11 + 5 + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + + 12 + 5 + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + + 13 + 5 + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + + 14 + 5 + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + + 15 + 5 + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + + 16 + 5 + + + 5 + 4 + + + + + + + + + Branch0__WaitingForTrain__to__TrainFromAboveA + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + SensorA + + + 18 + 17 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 19 + 17 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 20 + 17 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 21 + 17 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 22 + 17 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 23 + 17 + + + 17 + 136 + + + + + + + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + CmdBarriers + + + 190 + 189 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 191 + 189 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 192 + 189 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 193 + 189 + + + e174fc0d-80b0-4a9e-a530-ca239c249a50 + + S + + + 194 + 189 + + + 189 + 170 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + TrainFromAboveA + + + 171 + 170 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 172 + 170 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 173 + 170 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 174 + 170 + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + FALSE + FALSE + + 175 + 170 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 176 + 170 + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + + 177 + 170 + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + + 178 + 170 + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + + 179 + 170 + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + + 180 + 170 + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + + 181 + 170 + + + 170 + 136 + + + TrainFromAboveA__to__TrainFromAboveB + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + SensorB + + + 183 + 182 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 184 + 182 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 185 + 182 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 186 + 182 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 187 + 182 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 188 + 182 + + + 182 + 136 + + + + + + + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + TrainFromAboveB + + + 202 + 201 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 203 + 201 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 204 + 201 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 205 + 201 + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + FALSE + FALSE + + 206 + 201 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 207 + 201 + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + + 208 + 201 + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + + 209 + 201 + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + + 210 + 201 + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + + 211 + 201 + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + + 212 + 201 + + + 201 + 136 + + + TrainFromAboveB__to__WaitingForTrain + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + Not SensorB + + + 252 + 251 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 253 + 251 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 254 + 251 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 255 + 251 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 256 + 251 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 257 + 251 + + + 251 + 136 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + + + + 137 + 136 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 138 + 136 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 139 + 136 + + + 136 + 131 + + + + + + Branch1__WaitingForTrain__to__TrainFromBelowB + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + SensorB + + + 145 + 144 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 146 + 144 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 147 + 144 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 148 + 144 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 149 + 144 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 150 + 144 + + + 144 + 140 + + + + + + + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + CmdBarriers + + + 196 + 195 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 197 + 195 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 198 + 195 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 199 + 195 + + + e174fc0d-80b0-4a9e-a530-ca239c249a50 + + S + + + 200 + 195 + + + 195 + 151 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + TrainFromBelowB + + + 152 + 151 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 153 + 151 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 154 + 151 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 155 + 151 + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + FALSE + FALSE + + 156 + 151 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 157 + 151 + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + + 158 + 151 + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + + 159 + 151 + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + + 160 + 151 + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + + 161 + 151 + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + + 162 + 151 + + + 151 + 140 + + + TrainFromBelowB__to__TrainFromBelowA + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + SensorA + + + 164 + 163 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 165 + 163 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 166 + 163 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 167 + 163 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 168 + 163 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 169 + 163 + + + 163 + 140 + + + + + + + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + TrainFromBelowA + + + 220 + 219 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 221 + 219 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 222 + 219 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 223 + 219 + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + FALSE + FALSE + + 224 + 219 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 225 + 219 + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + + 226 + 219 + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + + 227 + 219 + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + + 228 + 219 + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + + 229 + 219 + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + + 230 + 219 + + + 219 + 140 + + + TrainFromBelowA__to__WaitingForTrain + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + Not SensorA + + + 259 + 258 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 260 + 258 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 261 + 258 + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + 0 + 0 + + 262 + 258 + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + FALSE + FALSE + + 263 + 258 + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + 0 + 0 + + 264 + 258 + + + 258 + 140 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + + + + 141 + 140 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 142 + 140 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 143 + 140 + + + 140 + 131 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + Branch0 + + + 132 + 131 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 133 + 131 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 134 + 131 + + + 23bdaa98-72ec-41f7-817b-9dede5697086 + + FALSE + FALSE + + 135 + 131 + + + 131 + 4 + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + WaitingForTrain + + + 25 + 24 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 26 + 24 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 27 + 24 + + + 24 + 4 + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + + + + 28 + 4 + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + + 29 + 4 + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + FALSE + FALSE + + 30 + 4 + + + 4 + -1 + + + + + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + + + + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + Common + 00000000-0000-0000-0000-000000000000 + Common properties + + + + + + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + + + + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Specific + 00000000-0000-0000-0000-000000000000 + Element type specific properties + + + + + + d336ba06-8a67-4e6b-9697-50f83ba6bdde + + + + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + d336ba06-8a67-4e6b-9697-50f83ba6bdde + Times + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Time definitions + + + + + + 20f0fc67-978d-438b-979f-eef294f34ee4 + + + + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 20f0fc67-978d-438b-979f-eef294f34ee4 + Actions + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Action definitions + + + + + + + + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 38391c6d-6d4a-42f8-8ee7-9f45e5adafa8 + Name + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + Name of the element + + + + + + 7d894980-aeea-405c-a0f6-e2b26429c58f + + + + False + False + True + True + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 7d894980-aeea-405c-a0f6-e2b26429c58f + Comment + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + Comment of the element + + + + + + 01580b27-6378-448b-8ecb-0e4b795b58d6 + + + + False + True + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 01580b27-6378-448b-8ecb-0e4b795b58d6 + Exclude + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + Exclude this element from build + + + + + + bc882c11-1e91-4dd8-a6b8-2075724ed18b + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + bc882c11-1e91-4dd8-a6b8-2075724ed18b + Symbol + 6f37ae05-4d8f-4ddc-9e69-c0e6bd6962c2 + Symbol configuration setting of the element + + + + + + e174fc0d-80b0-4a9e-a530-ca239c249a50 + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + e174fc0d-80b0-4a9e-a530-ca239c249a50 + Qualifier + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Qualifier of the action association + + + + + + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 6844a48e-46c2-4cc8-a185-a478f3b99cc0 + InitStep + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Sets the step as the initial step + + + + + + 23bdaa98-72ec-41f7-817b-9dede5697086 + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 23bdaa98-72ec-41f7-817b-9dede5697086 + Parallel + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Sets the branch to parallel mode + + + + + + cacf1a68-236e-47c2-b7b1-1cf9199718cb + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + cacf1a68-236e-47c2-b7b1-1cf9199718cb + MinTime + d336ba06-8a67-4e6b-9697-50f83ba6bdde + Defines the minimal active time for the step + + + + + + b693554c-1b01-4a8d-afde-9e3a46f7465d + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + b693554c-1b01-4a8d-afde-9e3a46f7465d + MaxTime + d336ba06-8a67-4e6b-9697-50f83ba6bdde + Defines the maximal active time for the step + + + + + + 62e1754b-7629-4e63-9cec-10ae0c536f1f + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 62e1754b-7629-4e63-9cec-10ae0c536f1f + Duplication + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Copies the implementation of step actions or transitions with the element. Otherwise, copies the reference to an existing implementation. + + + + + + 8294df19-5962-4dee-a874-1051dabb0e3e + + + + False + False + False + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 8294df19-5962-4dee-a874-1051dabb0e3e + Monitoring + 1c5c6e05-2bb7-4258-bcee-fd16a540c63c + Indicates transition monitoring setting. + + + + + + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + 700a583f-b4d4-43e4-8c14-629c7cd3bec8 + MainAction + 20f0fc67-978d-438b-979f-eef294f34ee4 + Name of the action to be called if the step is active + + + + + + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + a6b08bd8-b696-47e3-9cbf-7408b61c9ff8 + EntryAction + 20f0fc67-978d-438b-979f-eef294f34ee4 + Name of the action to be called if the step is activated + + + + + + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + + + + False + False + True + False + 11e03a9d-fc7b-4112-850c-ae33fb04a2b4 + a2621e18-7de3-4ea6-ae6d-89e9e0b7befd + ExitAction + 20f0fc67-978d-438b-979f-eef294f34ee4 + Name of the action to be called if the step is deactivated + + + + + + + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + + + 79a4fdd5-c859-4de4-aa8d-c03372c880c2 + + + Unspecified + 0 + Unspecified + + + None + 1 + None + + + Read + 2 + Read + + + Write + 3 + Write + + + ReadWrite + 4 + ReadWrite + + + INT + + + + + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + + + 6ecff51c-9b8f-4338-aab0-67c2b31b07e9 + + + None + 0 + None + + + Variable + 1 + Variable + + + Call + 2 + Call + + + INT + + + + + + + + + + 290 + + PROGRAM SFC_TEST + + + 291 + + VAR + + + 292 + + SensorA: BOOL; + + + 293 + + SensorB: BOOL; + + + 294 + + END_VAR + + + + + 293 + Standard + + False + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/Standard_ST.st b/GraphicalTesting/StandardPLC/application/Standard_ST.st new file mode 100644 index 0000000..9943c6e --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/Standard_ST.st @@ -0,0 +1,25 @@ +PROGRAM Standard_ST +VAR + xDoYouWork: BOOL; + xYayyyy: BOOL; +END_VAR + +// --- BEGIN IMPLEMENTATION --- + +xDoYouWork := TRUE; + +// some random comment +(* Some +random + multi + +line +comment + +*) +IF xDoYouWork THEN + xYayyyy := TRUE; + +ELSE + xYayyyy := FALSE; +END_IF \ No newline at end of file diff --git a/GraphicalTesting/StandardPLC/application/TaskConfiguration.xml b/GraphicalTesting/StandardPLC/application/TaskConfiguration.xml new file mode 100644 index 0000000..24e4ca0 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/TaskConfiguration.xml @@ -0,0 +1,124 @@ + + + + + + + True + + b0d50df9-cc5b-471e-b9d7-197d5a99c3a1 + f9c00bdb-2f18-4e80-863e-6da977e8f304 + TaskConfiguration + + ae1de277-a207-4a28-9efb-456c06bd52f3 + + 0 + + + + + + 829378c7-1267-51f4-b4ac-521cf1d5bbf9 + 53ecd2e5-d70a-5903-bab3-942f6b111150 + ccd76387-5c50-5aed-90e7-2fad510510da + 837ac483-1677-559b-8066-58eb0bf867af + 2fce9459-f52d-5106-a284-5b1ba25f58cc + 6aaa34df-c788-548e-a605-796ed125fcb6 + 1b7debd5-2424-518a-a4cc-7b6538957411 + 375b88a9-fec3-53fd-966d-d87dfdb105c1 + ccbac5e1-21c2-5fc1-94ff-1d5855925882 + f4c5c858-7cb3-5803-9646-9fe3082d62bc + d5cd0959-13c9-5347-b8d1-315299b2535a + ad2fce79-1837-5391-9e37-1df24b2f70d1 + 2433f898-297a-5757-be54-a58e387c5752 + 05fb4d09-8e96-5918-a033-9002184f3456 + 30434813-fff6-5bf5-92c8-9bd7c86e4f0c + + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + + 00000000-0000-0000-0000-000000000000 + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + False + + 12674cf4-d25e-4151-96b2-08a6cc6d1e52 + b0d50df9-cc5b-471e-b9d7-197d5a99c3a1 + Main + + 98a2708a-9b18-4f31-82ed-a1465b24fa2d + + 0 + + + Cyclic + + + + + + 1 + + True + + t#18ms + + + 1 + + + t#20ms + + + + + 30e3ab47-1b98-4397-9eff-c6534e91a6b8 + 205b1f78-9060-49b9-896f-0aab4eb3cc59 + + + Standard_ST + + + + SFC_TEST + + + + LD_TEST + + + + FB_TESTING + + + + False + True + -2 + + + b0d50df9-cc5b-471e-b9d7-197d5a99c3a1 + + StandardPLC + PLC Logic + Application + Task Configuration + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/Visualization Manager.service.txt b/GraphicalTesting/StandardPLC/application/Visualization Manager.service.txt new file mode 100644 index 0000000..5b163d9 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/Visualization Manager.service.txt @@ -0,0 +1,296 @@ + + + + + + + True + + 346833a2-b8bb-4a1d-be12-49edf9cc55c3 + f9c00bdb-2f18-4e80-863e-6da977e8f304 + Visualization Manager + + 4d3fdb8f-ab50-4c35-9d3a-d4bb9bb9a628 + + 0 + + + Disabled + + + Visualization + False + False + AutoDetect + 2000 + 2000 + False + 50000 + 400000 + False + False + True + Default, 3.5.16.0 (3S-Smart Software Solutions GmbH) + 100 + + VisuDialogs.Numpad + VisuDialogs.Keypad + VisuDialogs.TextinputWithLimits + False + False + False + False + False + VisuUserManagement.VUM_Login + VisuUserManagement.VUM_ChangePassword + VisuUserManagement.VUM_UserManagement + 00000000-0000-0000-0000-000000000000 + 00000000-0000-0000-0000-000000000000 + True + True + + + + + + + FB_Init + + + c7e2166b-97dd-4993-bada-4bbfb66ec8ac + + + + + FB_Exit + + + c5608289-c5cc-4147-aaa8-fad5eaf1a2af + + + + + FB_Reinit + + + ce81c39a-7582-4df2-8d4d-908c63dae772 + + + + NotImportant + c0519247-ee79-46c5-b44d-e337bed2ed1f + + + + + + 481037385728 + 549755813887 + 481037385728 + 549754765312 + 1048576 + + + + + + + ExecuteLooseCapture + + + db44e2e9-251e-4e32-865b-6ad832956598 + + + + + Init + + + c006e9d8-a2d0-49eb-8e96-b36e8bfec712 + + + + + FB_Exit + + + 1df4a714-e6f5-4fbf-a7c1-55931a0b9ad9 + + + + + ExecuteMouseDblClick + + + a124a2b6-3427-4528-ba94-a9a3e51ef24e + + + + + ExecuteMouseDown + + + 4bfa1c8c-98e7-430d-b134-affcadc6cf9e + + + + + ExecuteMouseUp + + + a9199d8b-ef44-41c5-8658-b65d688170f9 + + + + + ExecuteMouseEnter + + + 70fd46c1-8141-4c7e-91c8-820053991829 + + + + + FB_Reinit + + + 6239d873-3a40-4be9-b8dc-4b1df397757d + + + + + GetElementInfo + + + a3b59e56-e7f3-467f-b68d-4d4deb1bfedc + + + + + abstrGetDefaultCursor + + + 895a8e24-d2e8-47a2-8ef4-ab8c1adde1b2 + + + + + ExecuteDialogClosed + + + 3dd02592-c0d6-4327-807f-5e09d6918b76 + + + + + ExecuteKeyUp + + + 803c64ff-2010-4b2d-a723-8da71f8ad696 + + + + + ExecuteKeyDown + + + 202e88a4-d10d-4bbb-9284-50e5a5ca4be2 + + + + + ExecuteMouseMove + + + fc65267b-d612-4fed-a0e1-982e05bfba9a + + + + + Initialize + + + 6cbdcd7f-d922-418a-99ef-3679359317bd + + + + + ExecuteMouseLeave + + + 478f1b09-6f9e-4e81-8dde-ea1b9739df4e + + + + + FB_Init + + + 6b1b99e3-6280-48a4-859a-c97e58e472ed + + + + + ExecuteMouseClick + + + a6ff1a7a-47d1-4c19-86e4-76589c360358 + + + + NotImportant + 2de30ff1-b3ea-4c1e-9bc5-344ef440e3db + + + + True + True + + + + + 64d50120-051d-4bbe-ace6-e97984314aa8 + 44870d62-e28b-43f9-b98f-0473a3b132c8 + 9fae56b9-b14c-41c6-8cc7-8e40c4e249bf + 0c9f6a20-5848-419d-957b-31931d01c14b + 22fcdeb3-2b7e-417c-b2a5-afeae62350aa + 4313d649-c656-4f94-ae7d-0404c247540f + c30af6cf-89a0-46de-a616-bea3c710b716 + c5706b17-fd15-4ef8-91b6-5213fb5836e5 + ea23f4db-9316-4883-895a-ebf90c19d811 + 51bcf610-d5e6-4339-ab58-5a8e2010bb6b + d69a4065-30d5-4f6a-8d7c-5a91da761b6e + 22ff94e4-c92f-45ff-806d-39662fb8eed9 + c038b976-267a-40e2-a701-1d71b647f576 + 8f95026c-2442-4f17-be57-3bef3c8281e1 + 67d059b5-88ba-4d83-b87a-a7e239c9f2b7 + 9fe503c5-c6ac-4a5c-9564-38354c97acde + 708f4a82-e851-41bf-9466-211f4b4ec15f + 546ec7a1-f2d9-4adb-aa20-b98353538eef + c70c9e80-1f7a-43cf-bc9d-c574e69f8acd + c71dd610-1c28-4e3a-aca8-6e884990fcb8 + 2a8cfd92-0884-4c23-857e-64e89bb1dbda + 6014fccd-47e6-44ee-b4db-cd6ff3649eaa + b01ac028-20b6-456c-97ca-30313bf4bc58 + e61eac95-acfc-4a1d-afff-533f7b742008 + e9235d3e-5580-40f1-b8ce-06360bdaa802 + ac1a51dd-19bb-49fc-bc83-a9351de7ee3e + 1aacb564-6e44-44bf-94c7-d1b8fc9564c5 + 40c4786e-6c3a-4fc0-9e83-69ad01d756b9 + ca873530-db0c-44cb-b134-94179c18efc0 + d80608f7-c949-4e7c-b9af-0dc70db56235 + 2288d0f3-f5b4-471e-a28b-9c785c5c16cf + d751f19b-9d11-437f-b1e7-b39fb4d2d343 + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting/StandardPLC/application/Visualization.vis.xml b/GraphicalTesting/StandardPLC/application/Visualization.vis.xml new file mode 100644 index 0000000..a3f1774 --- /dev/null +++ b/GraphicalTesting/StandardPLC/application/Visualization.vis.xml @@ -0,0 +1,701 @@ + + + + + + + True + + 4b4a5658-f23b-461e-8789-077c2d695574 + f9c00bdb-2f18-4e80-863e-6da977e8f304 + Visualization + + f18bec89-9fef-401d-9953-2f11739a6808 + + 0 + + + + 3 + + + + + + + + + + 571893170 + + + + 2340015797 + HCENTER + + + 2565699834 + VCENTER + + + 4134387352 + NONE + + + 1603690730 + Arial + + + 4253639993 + 12 + + + 2729990903 + 0 + + + 1213979116 + 0 + + + 3488306084 + 4278190080 + + + 1647042231 + <toggle/tap variable> + + + 2812299069 + 4294967295 + + + 494569607 + 4278190080 + + + 3719097617 + 0 + + + 1649127785 + 588 + + + 357335551 + 298 + + + 2422045748 + 173 + + + 2134141914 + 66 + + + 1651471674 + True + + + 2341735680 + + + -2830136 + Element-Control-Color + + + + + 438423234 + + + -2830136 + Element-Alarm-Fill-Color + + + + + 3729828405 + + + 0 + 0 + -16777216 + Font-Standard + Arial + 12 + + -16777216 + Element-Button-FontColor + + + + + + 550940142 + 674 + + + 1473355128 + 331 + + + 493260384 + 4294967295 + + + 135947015 + 4278190080 + + + 2678395525 + 1 + + + 2478807622 + + + + 390574330 + Button Test: %u + + + 2477733581 + FB_TESTING.uiCurrSupplyVolt + + + 2597686782 + False + + + 823443203 + 41 + + + + Button + VisuFbElemButton + True + GenElemInst_1 + + + + a09d6f25-75d3-406f-85ff-b0c3a82d3c7a + 4b4a5658-f23b-461e-8789-077c2d695574 + + + 0 + + + + + 16777215 + + + + + False + False + 16777215 + False + + + + + + + + HasVisibilityAccess + + + 1aeece20-f66b-4232-92b1-6b0c211bfce2 + + + + + ContainsPoint + + + 1d82e146-0648-4282-94b7-768e7e7c49e9 + + + + + SetStaticState + + + 363c4b16-c894-432a-bacf-a45ccaddd76d + + + + + HasInputAccessIntern + + + 3e744d84-41d5-441e-a784-1b949677b271 + + + + + GetTooltip + + + 4f997422-5590-47e7-9b38-442f2faa0669 + + + + + GetDialogInterfaceSize + + + 3669a9fb-fa11-4581-92c7-8707ce55d86b + + + + + GetUpdateRects + + + 773393dc-c275-4fc0-879b-549a1ed96a9b + + + + + GetNamespace + + + 92e810b7-5164-4790-9641-659fa39c63ab + + + + + SetResult + + + f3aa4726-0cc0-4dc7-a64d-5c32b369d481 + + + + + GetTranslator + + + 7de83b06-fce9-472f-a3c6-614f497cd929 + + + + + Update + + + ea9d9b95-97e3-48e3-8243-9c8728a70539 + + + + + SetClientData + + + 0203f412-21b6-4343-bdb3-4d2598857239 + + + + + GetInstance + + + b324b92d-4243-48db-937d-c757f6c03499 + + + + + GetClientData + + + 77cc945a-30b8-4f66-9b7d-67287152bb2e + + + + + Paint + + + a7d5b928-5347-44f8-8b23-57ab344b9c46 + + + + + GetElementArray + + + 1cf97d7b-e95b-4be9-81ff-608cf957c3a2 + + + + + SetVisuFlagsInternal + + + 7adbe719-186c-4f28-8785-df2aaf60fe99 + + + + + GetText + + + 8c01ba72-6544-4cb9-84bc-1511b61f8e4c + + + + + GetTextProperties + + + f26b17f5-e015-445d-adde-3d33e97282d2 + + + + + Initialize + + + c58a1782-de69-40e7-9260-fbde35b45a89 + + + + + HasInputAccess + + + fffb67fc-325c-43b8-a41d-f7cdbd041b94 + + + + + HasVisibilityAccessIntern + + + 6fafe75f-5fdd-4e3a-ab51-d6a143f30f09 + + + + + GetSurroundingRect + + + afd9b12e-ff3e-45fc-9fcc-a5834e89209b + + + + + ElementInfo + + + 0a7cf83c-ea63-4ee9-ab46-6325dac33359 + + + + + GetResult + + + 0643cb0a-05a4-4046-8727-d019c005bf73 + + + + + GetLocalUsergroup + + + 2e3d2538-65ee-441f-92bf-80aee35f11a9 + + + + + GetName + + + 58667e8c-78a7-4c46-a689-89fd256162b8 + + + + + GetSize + + + 17f62a3c-e229-43f1-826b-93ff0fe8ae50 + + + + + SetDialogInterface + + + 98c03eef-350f-42b5-a725-de6d62961789 + + + + + FB_Reinit + + + 1f281ec0-334b-402a-b528-2273ea613c6b + + + + + FB_Exit + + + 0acdf951-a900-443a-8eaa-6f57a5e87e85 + + + + + GetInitializeVersion + + + 08524235-3d94-447a-99cc-f1dbe8cc2c92 + + + + + GetDialogInterface + + + 2bc006af-0c56-4da4-aad7-ff287194d14d + + + + + GetElementIdArray + + + 58d3a8b6-c5d3-4fb5-97e4-2a04c9eee93a + + + + + HandleInput + + + a5bc28d7-237b-4fca-b6d1-2b2e1370e82c + + + + + Destruct + + + c09bf3e7-349a-464f-acba-2c42868697fa + + + + NotImportant + 4b4a5658-f23b-461e-8789-077c2d695574 + + 76437654-1af3-4cfe-b897-a74708d6748f + f07f42e9-60c6-4a38-82d4-481c179f8473 + 365103e2-e114-489d-bb45-c1aa2bf8852b + + + + + + FB_Init + + + 0c112ad0-8dae-4adf-ac75-21ab50cb33b9 + + + + + FB_Exit + + + 73e957e1-708a-4907-96a5-a87640cbfcc2 + + + + + FB_Reinit + + + 6e2b75a7-a548-4090-abe2-6719ea95caa6 + + + + NotImportant + 8a0a9ea4-59ac-43cf-bb80-52dd1c1887b3 + + 81cff34b-1afa-42fd-8c1b-b8dc48d7cacf + + + + + + + ExecuteLooseCapture + + + 79d86b52-77d7-481a-9f5a-d115bc1deaf7 + + + + + ExecuteMouseDblClick + + + 1eac03a0-f73a-4ac6-8e91-a4b6a2ea588b + + + + + ExecuteMouseDown + + + 8aad3597-d523-424d-9c50-d9761e6e8cd6 + + + + + ExecuteMouseUp + + + 82a70ba4-e42a-43b9-b5a1-8f90d344bc70 + + + + + GetElementInfo + + + a19dd937-2161-41b2-bcb6-ca7f78916aa2 + + + + + abstrGetDefaultCursor + + + 5da54c43-d856-44fe-98f4-505cc3f51209 + + + + + ExecuteDialogClosed + + + e555a48f-05be-44c3-b675-fbbbc6a8ceed + + + + + ExecuteKeyUp + + + 65dfb130-83ad-4919-a581-adb64a6b10ba + + + + + ExecuteKeyDown + + + 4d9d65e2-1b12-47df-9de9-1f89fe1943d1 + + + + + ExecuteMouseMove + + + 5c0eeb45-2da9-401c-ae4e-16b551d3864d + + + + + Initialize + + + cbf0a683-59bb-41e2-8ab8-5bb5c394134f + + + + + ExecuteMouseEnter + + + 61305a06-0aca-4ded-a988-82c973f2f406 + + + + + ExecuteMouseLeave + + + 0b618d96-4309-4e93-b923-dc736674b524 + + + + + ExecuteMouseClick + + + d5550a8e-69de-4fd5-bfc8-e2ee4b0122e7 + + + + NotImportant + 9feab06b-467b-4727-b6d4-2b94ff432618 + + 514ce764-adc8-4f8f-ab62-404ad5703e4f + + 1 + + + + 2 + + VAR_IN_OUT + + + 3 + + + + + 1 + + END_VAR + + + + False + + 4140216668 + 0 + 1 + + 481037385728 + 549755813887 + 481037385728 + 549754765312 + 1048576 + + + + f9c00bdb-2f18-4e80-863e-6da977e8f304 + + StandardPLC + PLC Logic + Application + + -1 + + + + + diff --git a/GraphicalTesting_template_v1.project b/GraphicalTesting_template_v1.project new file mode 100644 index 0000000..94935d8 Binary files /dev/null and b/GraphicalTesting_template_v1.project differ diff --git a/README.md b/README.md index 35dad81..d2f425d 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,54 @@ 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. +Two service objects export **read-only** — written on every export, never imported (the project template carries the real objects): + +- The Library Manager exports its reference list as `.libraries.txt` (one line per library: name, version, vendor), so a review or bench check can see the version each library resolves to - shown where CODESYS reports it, and as the requested constraint (such as `*` for newest) where it does not. +- The Visualization Manager exports natively as `.service.txt` (recursively, to take in the target and web visualization settings under it; the global hotkey/key configuration is in the manager entry itself). Importing this object raises interactive overwrite dialogs, which is why it is not round-tripped. + +### 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: header text *) +(* Comment *) +│ TON_0 : TON CTU_0 : CTU +│ PowerOn ┌───────────┐ ┌───────────┐ PowerOff +├─────┤ ├──────────┤IN Q├────────────────┤CU Q├────(R)──────┤ +│ T#5S──┤PT ET│ PowerOff ┤ ├──┤RESET CV│ +│ └───────────┘ 10────────────┤PV │ +│ └───────────┘ +``` + +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. That form cannot carry comments, pragmas or attributes, so the rendering says on its first line that it is a rebuilt one, and the export summary counts how many POUs it happened to. A variable whose type the export does not carry reads `UNKNOWN` rather than being assumed to be a `BOOL`. + +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. + +An equivalent-Structured-Text rendering of the same networks is available but not written by the export. The ST states the logic exactly where the diagram can only approximate it - a block read through two of its output pins is one call, and no single-wire diagram can say so - but it is a rendering, not a translation, and must never be fed back into CODESYS. `tools/ladder/write_st.py` writes one from a PLCopen export when it is wanted, and its docstring says how to put it back on the export path. + +An EXECUTE box has no body of its own - the whole of it is inline ST carried alongside the box - so the rendering shows that text inside the box, below its pins, rather than an empty rectangle, and the equivalent ST guards it with the rung or pin condition that decides whether it runs. + +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. Each is headed by its title, as the editor heads it, with the network's comment on the line below; a network with no title puts its comment on the number line instead. + +CODESYS leaves out of the PLCopen export every network that carries no elements: an out-commented one goes entirely, comment included, and so does an empty one. The numbering comes from the native xml written beside the rendering, which lists every network the editor shows, so those keep their number and say why they have no diagram: + +``` +(* Network 2: Safely power off PLC when ignition is lower than 5V *) +(* out-commented in CODESYS - does not execute; diagram not exported, see the native xml *) +``` + +If the two cannot be lined up, the file says so at the top and falls back to numbering in export order, rather than showing numbers that quietly disagree with the editor. + +To render an exported PLCopen file by hand, to get plain ASCII instead of box drawing, or to print the equivalent Structured Text the export does not write: + +``` +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. @@ -186,10 +234,11 @@ The scripts run inside the CODESYS ScriptEngine, which embeds IronPython 2.7: - `main` is protected: changes go through a pull request and the CI checks must pass. - Changes that alter the export format or behaviour should be noted in [CHANGELOG.md](CHANGELOG.md). -CI runs two jobs on every pull request (see `.github/workflows/ci.yml` and `tools/ci/`): +CI runs three jobs on every pull request (see `.github/workflows/ci.yml` and `tools/ci/`): -- `ascii-check`: fails on any non-ASCII byte in `src/*.py`. -- `ironpython`: compiles every src file with real IronPython 2.7.12 (catches Python 2 syntax errors) and imports the library modules against a stubbed `scriptengine` (catches module-scope errors). +- `ascii-check`: fails on any non-ASCII byte in `src/*.py`, then compiles every src file with Python 3. +- `ironpython`: compiles every src file with real IronPython 2.7.12 (catches Python 2 syntax errors), imports the library modules against a stubbed `scriptengine` (catches module-scope errors), and runs the four renderer test suites under IronPython 2.7. +- `ladder`: runs the same four renderer test suites under Python 3. Note that `python -m py_compile` under Python 3 is not a sufficient local check; it misses both failure classes above. diff --git a/install.bat b/install.bat index 09faef0..b8bd7d6 100644 --- a/install.bat +++ b/install.bat @@ -1,2 +1,2 @@ -python %~dp0/install.py +python "%~dp0install.py" pause 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..daf5868 --- /dev/null +++ b/src/fbd_render.py @@ -0,0 +1,902 @@ +# 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, centred, stack +from ld_render import network_headers, render_declaration +from model import Assign, Call, Jump, Label, OutputRef, Signal, box_name + + +def _render_signal(node): + return Block([node.text], 0) + + +def _render_label(node): + # "NAME:" is how a label is written in ST, and how the ladder renderer + # already draws one. Inside (* *) it reads as documentation, which is the + # one thing a jump target is not. + return Block([node.name + ":"], 0) + + +def _render_jump(node, drawn, subs=None): + chars = charset.active() + tail = chars["H"] * 3 + ">> " + (node.target or "?") + if node.condition is None: + return Block([tail], 0) + source = _render(node.condition, drawn, subs) + 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 _store_head(node): + """The arrow head on a store: its set/reset marker, or its negation. + + A set or reset holds the target until the other one fires. Drawing it as + a plain arrow says the store follows its input, which is the opposite. + """ + if node.storage == "set": + return "(S)> " + if node.storage == "reset": + return "(R)> " + # The negation circle CODESYS draws on the pin, as an "o" on the wire. + return "o> " if node.negated else "> " + + +def _render_assign(node, drawn, subs=None): + chars = charset.active() + source = _render(node.source, drawn, subs) if node.source is not None else Block([""], 0) + lines = source.padded(source.width) + head = _store_head(node) + 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 _pin_arrow(box, pin): + """The inline form, for a store on a pin that also feeds a wire onward.""" + storage = box.stored_outputs.get(pin) + if storage == "set": + return " =S> " + if storage == "reset": + return " =R> " + return " =o> " if pin in box.negated_outputs else " => " + + +def _pin_head(box, pin): + """The arrow head on a store written straight onto an output pin. + + The same heads a store on a wire uses: "o>" for the negation bubble, + "(S)>" and "(R)>" for the set and reset a pin can carry. + """ + storage = box.stored_outputs.get(pin) + if storage == "set": + return "(S)> " + if storage == "reset": + return "(R)> " + return "o> " if pin in box.negated_outputs else "> " + + +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, read_pin, drawn, subs=None): + chars = charset.active() + input_blocks = [] + for _pin, source in call.inputs: + input_blocks.append(_render(source, drawn, subs) 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) + + # A VAR_IN_OUT pin arrives among the inputs, and a wire can still leave it + # on the right. It runs through the box and leaves level with where it + # enters, so that row is its own, and no output pin is placed on it. + names = [pin for pin, _assigned in call.outputs] + through_rows = {} + for index, pin_and_source in enumerate(call.inputs): + pin = pin_and_source[0] + if pin is not None and pin in call.wired_outputs and pin not in names: + through_rows[pin] = input_rows[index] + reserved = set(through_rows.values()) + + output_rows = [] + for index in range(len(call.outputs)): + if index < len(input_rows): + row = input_rows[index] + else: + # More outputs than inputs: the surplus hangs below the last pin. + base = input_rows[-1] if input_rows else -1 + row = base + index - len(input_rows) + 1 + if output_rows: + row = max(row, output_rows[-1] + 1) + while row in reserved: + row += 1 + output_rows.append(row) + + 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] + for pin in through_rows: + through_rows[pin] += shift + 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 "?" + + # A store written on an output pin hangs off that pin on a wire of its + # own, the way CODESYS draws it. Writing it inside the box put the target + # variable in among the pin names, where it reads as another pin. + out_at = {} + tail_at = {} + for index, pin_and_assignment in enumerate(call.outputs): + pin, assigned = pin_and_assignment + text = pin or "?" + if assigned and pin not in call.wired_outputs: + tail_at[output_rows[index]] = chars["H"] * 3 + _pin_head(call, pin) + assigned + elif assigned: + # The pin already carries a wire onward; a second thing hung off + # the same row would cross it. + text += _pin_arrow(call, pin) + 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, ""))) + # An EXECUTE box carries its inline ST as its body: the lines sit inside + # the box, below the pins, and widen it to the longest of them. Tabs are + # expanded to spaces so the box's right wall stays straight - a tab counts + # as one character but draws as several. + code = [line.expandtabs(4) for line in call.st_code] + widths += [len(line) + 2 for line in code] + inner = max(widths) + + body_last = box_last + len(code) + height = max(len(left), body_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]) + + # An output pin only breaks the box wall with a tee if a consumer is + # actually there to receive it - and a box read through two pins breaks + # it twice. The tee stays where every reader names the pin in text: it is + # the one mark that a reader takes the box's pin and not a variable of the + # same name, and that a copy of an operator is the same box. + pins = [pin for pin, _assigned in call.outputs] + live_output_rows = set() + for pin in call.wired_outputs: + if pin in pins: + live_output_rows.add(output_rows[pins.index(pin)]) + live_output_rows.update(through_rows.values()) + through = set(through_rows.values()) + + lines = [] + for row in range(height): + if row == box_first - 2: + box = centred(title, inner + 2) + elif row == box_first - 1: + box = chars["TL"] + chars["H"] * inner + chars["TR"] + elif row == body_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"] + wired_out = row in live_output_rows or row in tail_at + right_edge = chars["PIN_R"] if wired_out else chars["V"] + gap = inner - len(left_pin) - len(right_pin) + fill = chars["H"] if row in through else " " + box = left_edge + left_pin + fill * gap + right_pin + right_edge + tail_at.get(row, "") + elif box_last < row <= body_last: + # A body line of an EXECUTE box, inside the box below the pins. + text = code[row - box_last - 1] + box = chars["V"] + " " + text + " " * (inner - len(text) - 1) + chars["V"] + else: + box = " " * (inner + 2) + lines.append(left[row] + box) + + # The wire leaves on whichever output pin this consumer asked for. + pin_rows = {} + for index, pin in enumerate(pins): + pin_rows[pin] = output_rows[index] + pin_rows.update(through_rows) + + wanted = read_pin if read_pin is not None else call.active_output + connect_row = box_first + if wanted in pin_rows: + connect_row = pin_rows[wanted] + elif output_rows: + connect_row = output_rows[0] + + return Block(lines, connect_row, pin_rows) + + +# The boxes without an instance name that the render in progress named in text, +# by id. render_network clears it before each render and reads it after; it is +# module state only because a render copies its set of drawn boxes on the way. +_named = [] + + +def _reference(call, pin): + """The name of a box already drawn in this network, on the pin being read. + + An instance is named by its instance. An operator has no name to print, + and being stateless it costs nothing to draw again - so it is redrawn. A + box that carries inline ST, such as EXECUTE, has no instance either, but it + is not stateless: its body runs once, and a redraw prints the body again, + which reads as the statements running twice. It is named by its type, the + way the ladder renderer names a box it does not redraw, and numbered by + render_network when its type alone would not say which box it is. + """ + if call.instance_name: + base = call.instance_name + elif call.st_code: + base = box_name(call) + _named.append(id(call)) + else: + return None + text = base + "." + pin if pin else base + if pin in call.negated_outputs: + text = "NOT " + text + return text + + +# Stands in for a shared box while the branch that reads it is drawn, so the +# row the wire arrives on can be found once the branch has been composed. +# Composition is text, so a marker in the text is the cheapest way to carry a +# position through it, and it never survives into the output. One marker per +# pin, so a branch that reads two pins can say which wire arrives where. They +# are control characters, skipping the ones str.strip and str.split treat as +# whitespace, because a line ending in one is stripped like any other. +MARKERS = [chr(code) for code in list(range(1, 9)) + list(range(14, 28))] + + +class _Shared(object): + """The box a network is joined around, while its readers are composed. + + ``pins`` names the pins a reader may take a wire from; None allows any. + A read of any other pin falls back to naming the box in text, the way a + box already drawn is named, and so does a read once the markers run out. + """ + + def __init__(self, call, pins=None): + self.call = call + self.pins = pins + self.markers = {} + + def marker(self, pin): + """The stand-in for a wire from ``pin``, or None to name it instead.""" + if self.pins is not None and pin not in self.pins: + return None + if pin not in self.markers: + if len(self.markers) >= len(MARKERS): + return None + self.markers[pin] = MARKERS[len(self.markers)] + return self.markers[pin] + + +def _render(node, drawn, subs=None): + """Draw one tree. ``drawn`` holds the boxes this network has already shown. + + A block read by two of the network's outputs is one block that runs once. + Where both readers hang straight off it the fan-out draws it once and + branches, but a reader sitting behind another box is a tree of its own, + and drawing that tree from scratch put a second copy of the same instance + on the page - two timers where the program has one. The first tree to + reach a box draws it; the rest name the pin they read, exactly as the + ladder renderer does. + """ + call = node.call if isinstance(node, OutputRef) else node + if isinstance(call, Call): + pin = node.pin if isinstance(node, OutputRef) else call.active_output + if subs is not None and subs.call is call: + # Drawn once already, on the left of this branch; the wire into it + # comes from the junction rather than from another copy of the box. + marker = subs.marker(pin) + if marker is not None: + return Block([marker], 0) + if id(call) in drawn: + reference = _reference(call, pin) + if reference is not None: + return Block([reference], 0) + else: + drawn.add(id(call)) + return _render_call(call, pin, drawn, subs) + if isinstance(node, Assign): + return _render_assign(node, drawn, subs) + if isinstance(node, Jump): + return _render_jump(node, drawn, subs) + 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() + head = "o " if node.negated and not node.storage else _store_head(node) + return chars["H"] * 2 + head + (node.label or "?") + + +def _fanout_groups(source, outputs): + """[(rows, outputs)] - one group per output pin that is read. + + Outputs reading the same pin share one wire and are branched off it, so + they stack on consecutive rows under that pin. Outputs reading different + pins do not share anything: each leaves the box on its own pin's row, and + joining them into one junction column would draw two signals as one. + """ + order = [] + at_pin = {} + for output in outputs: + pin = output.source.pin if isinstance(output.source, OutputRef) else None + if pin not in at_pin: + at_pin[pin] = [] + order.append(pin) + at_pin[pin].append(output) + order.sort(key=lambda pin: source.pin_rows.get(pin, source.connect_row)) + + groups = [] + taken = set() + for pin in order: + rows = [] + row = source.pin_rows.get(pin, source.connect_row) + for output in at_pin[pin]: + while row in taken: + row += 1 + taken.add(row) + rows.append(row) + row += 1 + groups.append((rows, at_pin[pin])) + return groups + + +def _render_fanout(outputs, drawn): + """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, drawn) + # 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 + groups = _fanout_groups(source, outputs) + + tails = {} + joints = {} + verticals = set() + for rows, group in groups: + for row, output in zip(rows, group): + tails[row] = output + if len(rows) == 1: + joints[rows[0]] = chars["H"] + continue + # One wire, branched: the junction column belongs to this pin alone. + joints[rows[0]] = chars["T_DOWN"] + joints[rows[-1]] = chars["BL"] + for row in rows[1:-1]: + joints[row] = chars["T_RIGHT"] + for row in range(rows[0] + 1, rows[-1]): + verticals.add(row) + + # Only the row a wire actually leaves the box on is extended to the + # junction; the rows below it are carried by the junction column. + lines = source.padded(width, wire_rows=set(rows[0] for rows, _group in groups)) + + last = max(tails) + while len(lines) <= last: + lines.append(" " * width) + + out = [] + for row, line in enumerate(lines): + joint = joints.get(row, chars["V"] if row in verticals else " ") + tail = _assign_tail(tails[row]) if row in tails else "" + out.append(line + joint + tail) + + return Block(out, min(tails)) + + +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 - through an OutputRef each + when they read different pins of it. + """ + if len(outputs) < 2: + return None + if not all(isinstance(output, Assign) for output in outputs): + return None + sources = [output.source for output in outputs] + if sources[0] is None: + return None + boxes = [source.call if isinstance(source, OutputRef) else source for source in sources] + if not all(box is boxes[0] for box in boxes): + return None + # Readers spread across more than one pin of one instance box need a + # column per pin: _render_joined draws that. _render_fanout stacks every + # reader in a single column and pushes a lower pin's wire onto whatever + # row is free, which for a timer read on Q and ET lands the ET store on + # the box's bottom border. Hand those to _render_joined instead. + # The same holds for a box with no instance once a pin has two readers: + # those take consecutive rows, and a lower pin's reader is pushed below them. + read = [source.pin for source in sources if isinstance(source, OutputRef)] + pins = set(read) + if len(pins) > 1: + if getattr(boxes[0], "instance_name", None) or len(read) > len(pins): + return None + return sources[0] + + +def _shared_call(outputs): + """The one box this network reads from more than one place, or None. + + A box read twice is one box that runs once, and the second reader is a + branch off its pin - not a second copy, and not a name in text. An + instance qualifies: an operator has no name, no state, and nothing is + gained by joining two copies of it read on one pin. An operator read on two + different pins is the exception - no single wire can carry both, and the + fan-out that took it put the second pin's reader on the box's bottom + border - so where no instance is shared, it qualifies too. + + More than one shared box in a network needs a real two-dimensional + layout, which this renderer does not have; those fall back to naming the + box, which is wrong-looking but never wrong. + """ + counts = {} + order = [] + pins_read = {} + + def walk(node): + call = node.call if isinstance(node, OutputRef) else node + if isinstance(call, Call): + pin = node.pin if isinstance(node, OutputRef) else call.active_output + pins_read.setdefault(id(call), set()).add(pin) + if id(call) in counts: + counts[id(call)] += 1 + return + counts[id(call)] = 1 + order.append(call) + for _pin, source in call.inputs: + if source is not None: + walk(source) + elif isinstance(node, Assign): + if node.source is not None: + walk(node.source) + elif isinstance(node, Jump): + if node.condition is not None: + walk(node.condition) + + for tree in outputs: + walk(tree) + + shared = [call for call in order if counts[id(call)] > 1 and call.instance_name] + if not shared: + shared = [call for call in order if counts[id(call)] > 1 and len(pins_read[id(call)]) > 1] + return shared[0] if len(shared) == 1 else None + + +def _entry_rows(block, shared): + """[(row, pin)] for every wire into the shared box, in row order. + + Each marker is replaced by a piece of wire, so the branch's own lead-in + joins up with the junction it is placed against. + """ + chars = charset.active() + entries = [] + for row, line in enumerate(block.lines): + for pin, marker in shared.markers.items(): + if marker in line: + line = line.replace(marker, chars["H"]) + entries.append((row, pin)) + block.lines[row] = line + entries.sort(key=lambda entry: entry[0]) + return entries + + +def _reads_pins(tree, call): + """The pins a tree reads ``call`` through, in the order it meets them.""" + found = [] + + def walk(node): + inner = node.call if isinstance(node, OutputRef) else node + if inner is call: + pin = node.pin if isinstance(node, OutputRef) else call.active_output + if pin not in found: + found.append(pin) + return + if isinstance(inner, Call): + for _pin, source in inner.inputs: + if source is not None: + walk(source) + elif isinstance(node, Assign): + if node.source is not None: + walk(node.source) + elif isinstance(node, Jump): + if node.condition is not None: + walk(node.condition) + + walk(tree) + return found + + +def _split_readers(outputs, call): + """([(tree, pins)], [tree]) - the outputs that read the shared box, and the rest.""" + readers = [] + others = [] + for tree in outputs: + pins = _reads_pins(tree, call) + if pins: + readers.append((tree, pins)) + else: + others.append(tree) + return readers, others + + +def _pin_order(pin): + """A tie-breaker for pins that share a row, so a layout never follows set order. + + Two pins share a row only when one has no row of its own on the box. Set and + dict order then decided the layout, and that order differs between runs and + between interpreters. + """ + return "" if pin is None else pin + + +def _place_branches(branches, pin_rows, default_row): + """{index: top row} for each composed branch. + + Branches are taken pin by pin down the box, and stacked so no two overlap: + the first reader of a pin sits level with the pin, and each later one goes + under whatever came before it. A branch that reads several pins is placed + by its topmost pin, and last among that pin's readers - the one position + from which its other wires can arrive without crossing anything. + """ + + def row_of(pin): + return pin_rows.get(pin, default_row) + + top_pin = [] + for _block, entries in branches: + top_pin.append(min(entries, key=lambda entry: (row_of(entry[1]), entry[0]))[1]) + + order = [] + for pin in sorted(set(top_pin), key=lambda pin: (row_of(pin), _pin_order(pin))): + group = [index for index, read in enumerate(top_pin) if read == pin] + group.sort(key=lambda index: len(set(read for _row, read in branches[index][1])) > 1) + order.extend(group) + + tops = {} + next_top = None + for index in order: + block, entries = branches[index] + entry = min([row for row, read in entries if read == top_pin[index]]) + wanted = row_of(top_pin[index]) - entry + tops[index] = wanted if next_top is None else max(wanted, next_top) + next_top = tops[index] + len(block.lines) + return tops + + +def _reader_rows(branches, tops): + """{pin: [row]} - the rows the wires from each pin arrive on, in order.""" + rows = {} + for index, block_and_entries in enumerate(branches): + for row, pin in block_and_entries[1]: + rows.setdefault(pin, []).append(tops[index] + row) + for pin in rows: + rows[pin].sort() + return rows + + +def _junction(row, position, columns, reader_rows, row_of): + """One cell of the junction columns between a shared box and its readers. + + Each pin that is read has a column of its own, the lowest pin's nearest + the box. A column carries the pin's wire down from the pin's row to the + last reader of it, breaking out to each reader on the way. Every other + cell is a wire passing through - on its way out to a column further from + the box, or in from one nearer it to a reader - or nothing. + """ + chars = charset.active() + pin = columns[position] + pin_row = row_of(pin) + rows = reader_rows[pin] + if row == pin_row: + if rows[0] != pin_row: + return chars["TR"] + return chars["T_DOWN"] if len(rows) > 1 else chars["H"] + if row in rows: + return chars["BL"] if row == rows[-1] else chars["T_RIGHT"] + if pin_row < row < rows[-1]: + return chars["V"] + for other, other_pin in enumerate(columns): + if other > position and row == row_of(other_pin): + return chars["H"] + if other < position and row in reader_rows[other_pin]: + return chars["H"] + return " " + + +def _compose_branches(readers, call, drawn, every_pin): + """[(block, entries)] - each reader drawn on its own, its wires marked. + + With ``every_pin`` each read of the shared box becomes a wire; without + it only the first pin a reader takes is wired and the rest are named in + text, the way a box already drawn is named. + """ + branches = [] + for tree, pins in readers: + shared = _Shared(call, None if every_pin else pins[:1]) + block = _render(tree, drawn, shared) + branches.append((block, _entry_rows(block, shared))) + return branches + + +def _wires_cross(branches, tops, pin_rows, default_row): + """True when the placed branches cannot be wired without a crossing. + + A pin's column runs from the pin's row down to the last reader of it, + and a lower pin's column sits inside a higher pin's. Every wire is then + clear of every column it passes only if each pin's readers all sit below + every reader of the pins above it, and none sits above its own pin. + """ + + def row_of(pin): + return pin_rows.get(pin, default_row) + + reader_rows = _reader_rows(branches, tops) + last = None + for pin in sorted(reader_rows, key=lambda pin: (row_of(pin), _pin_order(pin))): + rows = reader_rows[pin] + if rows[0] < row_of(pin): + return True + if last is not None and rows[0] <= last: + return True + last = rows[-1] + return False + + +def _render_joined(readers, call, drawn): + """Draw a shared box once and branch its readers off the pins they read. + + The box goes on the left; each reader is composed on its own to the right + of it and hangs off a junction column, level with the row its wire leaves + the box on. Readers of one pin share a column, which is what the editor + draws; readers of different pins get a column each, because joining two + pins into one column draws two signals as one. + + A reader that takes two pins from the box gets two wires, unless the + wires would then have to cross: then it keeps the wire from the first + pin it reads and names the other in text, which is wrong-looking but + never wrong. + """ + chars = charset.active() + drawn.add(id(call)) + # Against this network's drawn boxes, not a fresh set: a box drawn in the + # shared box's inputs is on the page, and a reader elsewhere that reaches it + # must name it rather than draw it - and an EXECUTE body - a second time. + source = _render_call(call, None, drawn) + default_row = source.connect_row + + for every_pin in (True, False): + shown = set(drawn) + branches = _compose_branches(readers, call, shown, every_pin) + tops = _place_branches(branches, source.pin_rows, default_row) + if not every_pin or not _wires_cross(branches, tops, source.pin_rows, default_row): + break + drawn.update(shown) + + shift = -min([top for top in tops.values()] + [0]) + for index in tops: + tops[index] += shift + + def row_of(pin): + return source.pin_rows.get(pin, default_row) + shift + + reader_rows = _reader_rows(branches, tops) + # Inner to outer: the lowest pin nearest the box, so that no column has + # to be crossed by a wire leaving the box above it. + columns = sorted(reader_rows, key=lambda pin: (row_of(pin), _pin_order(pin)), reverse=True) + leaves = set(row_of(pin) for pin in columns) + + lines = [" " * source.width] * shift + list(source.lines) + height = max([len(lines)] + [tops[index] + len(block.lines) for index, (block, _entries) in enumerate(branches)]) + + # The wire leaves the box once per pin that is read; everything below that + # is carried by the junction column, so only a pin's own row is filled + # across to it. Filling every reader row drew a wire out of the box's + # bottom border. + width = source.width + 2 + out = [] + for row in range(height): + line = lines[row] if row < len(lines) else "" + line += (chars["H"] if row in leaves else " ") * (width - len(line)) + for position in range(len(columns)): + line += _junction(row, position, columns, reader_rows, row_of) + tail = "" + for index, block_and_entries in enumerate(branches): + block = block_and_entries[0] + if tops[index] <= row < tops[index] + len(block.lines): + tail = block.lines[row - tops[index]] + break + out.append((line + tail).rstrip()) + return out + + +def _render_outputs(outputs, drawn): + """The diagram of one network's outputs, joined or branched as they share.""" + if _shared_source(outputs) is not None: + return _render_fanout(outputs, drawn).lines + + shared = _shared_call(outputs) + if shared is not None: + readers, others = _split_readers(outputs, shared) + lines = _render_joined(readers, shared, drawn) + # An output that reads nothing from the shared box is a drawing of its + # own, and goes below the joined one rather than into its columns. + for tree in others: + lines.extend(_render(tree, drawn).lines) + return lines + + lines = [] + for tree in outputs: + lines.extend(_render(tree, drawn).lines) + return lines + + +def render_network(network): + """Render one network, which may drive several outputs from one source. + + An EXECUTE box's inline ST is its body; _render_call draws it inside the + box, below the pins, wherever the box sits - as the network's own output, + behind the store its ENO feeds, or behind another box. + """ + outputs = getattr(network, "outputs", [network]) + unnamed = _unnamed_calls(outputs) + for call in unnamed: + call.ordinal = None + + # Per network: a box drawn for one output must not be drawn again for the + # next, but a box shared between two networks is two boxes on the page. + del _named[:] + lines = _render_outputs(outputs, set()) + # Which boxes the text names is known only once the network is drawn, and + # a box's title is written when it is drawn - so a numbered network draws + # twice. See box_name. + if _number_named_calls(unnamed, set(_named)): + del _named[:] + lines = _render_outputs(outputs, set()) + return lines + + +def _unnamed_calls(outputs): + """The boxes with no instance name in a network, in the order the trees reach them. + + The output trees in order, and within a tree a box's inputs before the box. + That is fixed by the network, not by where the renderer places a box: a + shared box is drawn first, so a box in its inputs can be drawn above one + numbered before it. + """ + found = [] + seen = set() + + def walk(node): + inner = node.call if isinstance(node, OutputRef) else node + if isinstance(inner, Call): + if id(inner) in seen: + return + seen.add(id(inner)) + for _pin, source in inner.inputs: + if source is not None: + walk(source) + if not inner.instance_name: + found.append(inner) + elif isinstance(node, Assign): + if node.source is not None: + walk(node.source) + elif isinstance(node, Jump): + if node.condition is not None: + walk(node.condition) + + for tree in outputs: + walk(tree) + return found + + +def _number_named_calls(unnamed, named): + """Number the boxes of each type that the text names ambiguously. + + Only a type with two or more such boxes in the network, one of which the + text names. Returns True when any box was numbered. + """ + groups = {} + order = [] + for call in unnamed: + if call.type_name not in groups: + groups[call.type_name] = [] + order.append(call.type_name) + groups[call.type_name].append(call) + + numbered = False + for type_name in order: + group = groups[type_name] + if len(group) < 2 or not any(id(call) in named for call in group): + continue + for index, call in enumerate(group): + call.ordinal = index + 1 + numbered = True + return numbered + + +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): + lines.extend(network_headers(index + 1, network)) + 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..0615e8e --- /dev/null +++ b/src/graphical_export.py @@ -0,0 +1,373 @@ +# 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. + +An equivalent-ST rendering of the same networks exists in st_render, and it +says things a single-wire diagram cannot - a block read through two of its +output pins is one call. The export does not write one; tools/ladder/write_st.py +does, on demand, and its docstring says how to put it back on this path. + +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 native_networks +import parse_fbd +import parse_ld +import plcopen +from util import open_utf8 + +# Suffixes for the derived files. Deliberately not .st: these are not +# importable and must never be mistaken for source. import_from_files +# dispatches on ".xml" and ".st", and os.path.splitext sees ".txt" for both of +# these, so both are ignored by construction. +RENDERED_SUFFIX = ".txt" + +# Said in the file itself when the editor's numbering could not be recovered. +# Numbering silently adrift from the editor is how an off-by-one review +# happens; a reviewer has to be able to see the doubt in the file they read. +ALIGNMENT_WARNING = ( + "(* WARNING: could not line these networks up with the native export;" + " the numbering may not match the CODESYS editor *)" +) + +# 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, + "members_missing": 0, + "alignment_failures": 0, +} + +STATS = dict(EMPTY_STATS) + +# Names behind STATS["skipped"], so the summary can say *what* was skipped. +# A bare count reads like something went missing; a name plus the reason +# (SFC/CFC have no renderer) answers the question before it is asked. +SKIPPED_POUS = [] + + +def reset_stats(): + STATS.update(EMPTY_STATS) + del SKIPPED_POUS[:] + + +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"], + ) + ) + if SKIPPED_POUS: + shown = SKIPPED_POUS[:6] + names = ", ".join(shown) + if len(SKIPPED_POUS) > len(shown): + names += ", +%d more" % (len(SKIPPED_POUS) - len(shown)) + line += " (no renderable body - SFC/CFC: %s)" % names + # 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." + # No file at all for a member is deliberate, but it is still a POU with no + # rendering beside its xml, so it has to be said rather than inferred. + if STATS["members_missing"]: + line += "\n NOTE: %d action/transition/method export(s) did not carry the" % STATS[ + "members_missing" + ] + line += " member's own body; nothing was written for those - review their native xml." + if STATS["alignment_failures"]: + line += "\n NOTE: %d POU(s) could not be lined up with their native export;" % STATS[ + "alignment_failures" + ] + line += " those are numbered in export order and say so at the top." + 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 _write_lines(path, lines): + """Write one rendering, newline-terminated, as UTF-8.""" + with open_utf8(path, "w") as f: + f.write(u"\n".join(lines)) + f.write(u"\n") + + +def _render_pous(plcopen_path, member_name=None): + """(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. + + With a member_name, only that member's own body qualifies. The exported + file also carries the parent POU's body, and rendering that instead is + exactly the foreign-dump defect this parameter exists to prevent. + """ + found = [] + if member_name is None: + bodies = plcopen.iter_bodies(plcopen_path) + else: + bodies = plcopen.iter_member_bodies(plcopen_path, member_name) + for pou_elem, language, body in bodies: + entry = RENDERERS.get(language) + if entry is None: + continue + parser, art_renderer = entry + pou = parser.pou_from_body(pou_elem, body) + if member_name is not None: + lowered = pou.name.lower() + if lowered != member_name.lower() and not lowered.endswith("." + member_name.lower()): + # The body came from a member nested in the parent pou, whose + # name the parser picked up. Title the rendering for what it + # actually shows - and flag it, because the declaration in + # the export is the parent's and the rendering should say so. + pou.name = pou.name + "." + member_name + pou.member_of_parent = True + found.append((pou, art_renderer)) + return found + + +def _joined(blocks): + """One POU rendering after another, with the blank lines tidied up.""" + lines = [] + for block in blocks: + lines.extend(block) + lines.append(u"") + while lines and lines[-1] == u"": + lines.pop() + return lines + + +def render_plcopen(plcopen_path, declaration_text=None, member_name=None, native_path=None): + """The diagram lines for a PLCopen file, or [] if none apply. + + The declaration and a diagram per network. An equivalent-ST rendering is + written by st_render and reachable from tools/ladder/render.py, but the + export does not write one: it was tried in the same file, which was worse + than either notation alone, and then in a file of its own, which is not + what is wanted for now. + + ``member_name`` restricts the rendering to that sub-POU member's own + body; see _render_pous. ``native_path`` is the native export written + beside the rendering, read back for the editor's own network list; see + native_networks. + """ + started = time.time() + pous = _render_pous(plcopen_path, member_name) + 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 + + # The editor's own network list, where one is available. It belongs to one + # POU and carries nothing to say which, so a file holding more than one + # renderable POU cannot use it - and that counts as a failure to line them + # up rather than as a reason to say nothing. + warnings = [] + native = native_networks.read_networks(native_path) if native_path is not None else None + if native and pous: + # No POU means nothing to number - a member export that carried no + # renderable body of its own, say. Running the alignment on it counted + # a spurious failure and printed a "could not line up" note for a POU + # that was never drawn. + aligned = native_networks.align(native, pous[0][0].networks) if len(pous) == 1 else None + if aligned is None: + STATS["alignment_failures"] += 1 + warnings.append(ALIGNMENT_WARNING) + else: + pous[0][0].networks = aligned + + # A member's export carries the parent's declaration, not its own, so + # both renderings have to open by saying whose declaration they show. + notes = [] + for pou, _art_renderer in pous: + head = list(warnings) + # The note is true only when the rendering opens with the parent's + # declaration. A graphical method carries its own, passed in as + # declaration_text, so there the note would misdescribe what follows. + if getattr(pou, "member_of_parent", False) and declaration_text is None: + head.append(u"(* " + pou.name + u" - the declaration below is the parent POU's *)") + notes.append(head + [u""] if head else []) + + started = time.time() + drawn = [] + for index, entry in enumerate(pous): + pou, art_renderer = entry + if pou.declaration_text: + STATS["verbatim_declarations"] += 1 + else: + STATS["fallback_declarations"] += 1 + drawn.append(notes[index] + art_renderer.render_pou(pou)) + STATS["draw_seconds"] += time.time() - started + + return _joined(drawn) + + +# 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, member_name=None): + """Export obj as PLCopen xml, render it, and write the derived files. + + Returns True if files were written. SFC and CFC bodies parse to nothing + renderable, so they are skipped rather than producing empty files. + + ``member_name`` marks obj as a sub-POU member (action, transition, + graphical method) whose PLCopen export wraps it in its *parent* POU. + Only the member's own body is rendered then - never the parent's, whose + networks under the member's filename would have a reviewer reading a + different POU. If the export carries no such body, nothing is written at + all: an absent rendering sends the reviewer to the native xml, a foreign + one does not. + + 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, draw and ST time. + textual_declaration = getattr(getattr(obj, "textual_declaration", None), "text", None) + lines = render_plcopen(temp_path, textual_declaration, member_name, base_path + ".xml") + if not lines: + if member_name is not None: + # No file at all is the honest outcome: an absent rendering + # sends the reviewer to the native xml, a foreign one does not. + STATS["members_missing"] += 1 + print( + "WARNING: the PLCopen export of " + + obj.get_name() + + " carries no renderable body for the member itself;" + + " nothing written - review the native xml" + ) + else: + # Members announce themselves above; these are the SFC/CFC + # bodies no renderer exists for. + SKIPPED_POUS.append(obj.get_name()) + STATS["skipped"] += 1 + return False + + _write_lines(base_path + RENDERED_SUFFIX, lines) + 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..1d14074 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,12 @@ 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) + # member_name: the PLCopen export of a sub-POU wraps it in its parent, + # parent body included. Without the name, the rendering draws the + # parent's networks under this member's filename. + write_rendered_text(child_obj, base, member_name=child_obj.get_name()) def import_method_st(child, dir_path, dir_parent_obj, import_dir_fn): @@ -232,6 +236,10 @@ 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) + # member_name: see export_method. An action's export carries the whole + # parent POU - rendering without the name is how the HMI PLC_PRG.ACT_* + # dumps came to describe the parent instead of the action. + write_rendered_text(child_obj, base, member_name=child_obj.get_name()) def export_action(child_obj, parent_obj, parent_folder_path, export_child_fn): @@ -301,6 +309,123 @@ def import_sub_pou(child, dir_path, dir_parent_obj, import_dir_fn): parent_obj.import_native(full_path) +def _probe_attribute(obj, names): + """First truthy attribute from names, swallowing property getters that raise. + + ScriptEngine objects are .NET objects whose property set varies between + CODESYS versions, and a property that exists can still throw when read. + """ + for name in names: + try: + value = getattr(obj, name, None) + except Exception: + value = None + if value: + return value + return None + + +def _describe_library_reference(reference): + """One line for a library reference, from whatever this build exposes. + + The reference object's shape is version-dependent and mostly undocumented, + so named attributes are probed and str() is the floor - IronPython renders + a .NET object's ToString, which for a reference is its display name. + """ + name = _probe_attribute(reference, ("display_name", "name")) + if not name: + return u"%s" % (reference,) + line = u"%s" % name + version = _probe_attribute(reference, ("effective_version", "resolved_version", "version")) + if version is not None and (u"%s" % version) not in line: + line += u", " + (u"%s" % version) + company = _probe_attribute(reference, ("company",)) + if company is not None and (u"%s" % company) not in line: + line += u" (" + (u"%s" % company) + u")" + # A placeholder reference displays as "#Util", which pins no version at + # all - and the placeholder libraries are exactly the ones a bench check + # is most likely to need. Show what it resolves to when the build says. + resolution = _probe_attribute(reference, ("effective_resolution", "default_resolution", "resolution")) + if resolution is not None and (u"%s" % resolution) not in line: + line += u" -> " + (u"%s" % resolution) + return line + + +def _library_reference_lines(lib_manager_obj): + """One display line per configured library reference, in manager order.""" + lines = [] + try: + references = lib_manager_obj.references + except Exception: + references = None + if references is not None: + for reference in references: + lines.append(_describe_library_reference(reference)) + if lines: + return lines + # IScriptLibManObject.get_libraries returns the list of library names - + # poorer (no per-reference detail to probe) but documented on every build + # since V3.5.5.0. + return [u"%s" % name for name in lib_manager_obj.get_libraries()] + + +def export_library_manager(child_obj, parent_obj, parent_folder_path, export_child_fn): + """Write the library references as a read-only .libraries.txt. + + Library *behaviour* is not exportable - but which exact library versions + the project resolves is, and a bench check of a library is only meaningful + against the version it characterises. The .txt suffix is ignored by the + importer by construction, and the object stays out of + OBJECT_TYPE_TO_EXPORT_FUNCTION deliberately: membership there makes + remove_tracked_objects delete the manager on import, and nothing would + recreate it. + + Best-effort: a failure warns and the export carries on. + """ + try: + lines = _library_reference_lines(child_obj) + path = os.path.join(parent_folder_path, child_obj.get_name() + ".libraries.txt") + with open_utf8(path, "w") as f: + f.write(u"(* Library references - regenerated by Export To Files; read-only, never imported *)\n") + for line in lines: + f.write(line + u"\n") + except Exception as error: + print("WARNING: could not export the library list of " + child_obj.get_name() + ": " + repr(error)) + + +def export_visualisation_manager(child_obj, parent_obj, parent_folder_path, export_child_fn): + """Export the visualisation manager natively as a read-only .service.txt. + + The manager carries configuration nothing else exports - the global hotkey + (key configuration) mapping among it - but importing it back makes CODESYS + raise an interactive overwrite dialog on every later import, which is why + it was dropped from the export entirely. Read-only is the middle ground: + the .service.txt suffix is ignored by the importer by construction + (dispatch is on .xml and .st), the project template still carries the real + object, and the configuration becomes reviewable. recursive=True for the + target and web visualisations that live under the manager; the key + configuration is inside the manager entry itself, not in a child. + + Best-effort: a failure warns and the export carries on. + """ + try: + write_native( + child_obj, os.path.join(parent_folder_path, child_obj.get_name() + ".service.txt"), recursive=True + ) + except Exception as error: + print("WARNING: could not export " + child_obj.get_name() + " read-only: " + repr(error)) + + +# Read-only, informational exports for objects the importer must never touch. +# Deliberately a separate table from OBJECT_TYPE_TO_EXPORT_FUNCTION: that +# membership is also what remove_tracked_objects deletes on import, and these +# objects are carried by the project template, not the import. +SERVICE_EXPORT_FUNCTIONS = { + ObjectType.LIBRARY_MANAGER: export_library_manager, + ObjectType.VISUALISATION_MANAGER: export_visualisation_manager, +} + + OBJECT_TYPE_TO_EXPORT_FUNCTION = { ObjectType.FOLDER: export_folder, ObjectType.POU: export_pou, diff --git a/src/layout.py b/src/layout.py new file mode 100644 index 0000000..ed06714 --- /dev/null +++ b/src/layout.py @@ -0,0 +1,72 @@ +# 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, pin_rows=None, sink_rows=None): + self.lines = lines + self.connect_row = connect_row + # For a box: the row each output pin sits on, so a caller branching + # several wires off it can leave each one level with the pin it + # reads instead of guessing. + self.pin_rows = pin_rows if pin_rows is not None else {} + # Rows that end in a coil, a return or another sink and so run to the + # right power rail on their own, rather than merging back into one + # wire. Two coils off one contact are two rung ends, not a loop. + self.sink_rows = set(sink_rows) if sink_rows is not None else set() + + @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 centred(text, width): + """``text`` centred in ``width``, with any odd space on the right. + + str.center splits an odd remainder the other way round under IronPython + 2.7 than under CPython 3, so a file rendered inside CODESYS and the same + file rendered by the dev CLI differ by one column on any box whose title + needs odd padding. Doing the arithmetic here settles it: the left padding + is the floor, which is what CODESYS itself produces. + """ + if width <= len(text): + return text + lead = (width - len(text)) // 2 + return " " * lead + text + " " * (width - len(text) - lead) + + +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..05c273d --- /dev/null +++ b/src/ld_render.py @@ -0,0 +1,806 @@ +# 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, centred +from model import BLOCK, COIL, CONTACT, IN_VARIABLE, Element, Empty, Parallel, Series, box_name, parallel, series + +# The letter a contact carries for edge detection, reused on a block's power +# pin so both read the same. +EDGE_MARKER = {"rising": "P", "falling": "N"} + +POU_TYPE_KEYWORDS = { + "program": "PROGRAM", + "functionBlock": "FUNCTION_BLOCK", + "function": "FUNCTION", +} + + +def _one_line(text): + """Comment text safe to put inside a generated (* *) block. + + A comment can span lines and can contain "*)", either of which would + terminate the block early and leave the rest of it as code. + """ + return text.replace("\r", " ").replace("\n", " ").replace("*)", "* )") + + +def network_headers(number, network): + """The header lines above one network: its number, title and comment. + + CODESYS keeps a network's title separately from its comment and draws the + title above it, as the network's heading. So the title goes on the number + line and the comment below it, in the order the editor shows them. A + network with no title puts its comment on the number line instead, rather + than spending a line on an empty heading - most networks have one or the + other, not both. + """ + comment = _one_line(network.comment or "").lstrip("/").strip() + title = _one_line(getattr(network, "title", "") or "").lstrip("/").strip() + + header = "(* Network " + str(number) + heading = title or comment + if heading: + header += ": " + heading + lines = [header + " *)"] + if title and comment: + lines.append("(* " + comment + " *)") + label = _one_line(getattr(network, "label", "") or "").strip() + if label: + # CODESYS keeps the label on the network; PLCopen exports it as a + # loose element, so it is only known here when the native export has + # been read. Written as ST writes it - a jump target is program + # structure, and inside (* *) it would read as a comment. + lines.append(label + ":") + note = getattr(network, "note", None) + if note: + lines.append("(* " + note + " *)") + return lines + + +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.storage == "set": + # The same marker a set coil carries: this store holds until a reset. + label = "(S) " + label + elif element.storage == "reset": + label = "(R) " + label + elif element.negated: + label = "NOT " + label + return "[" + label + "]", "" + + +def _pin_arrow(box, pin): + """The inline form, for a store on the pin the rung's wire leaves by. + + "=o>" is "=>" with the negation bubble: the pin stores its inverse. "=S>" + and "=R>" are the set and reset a pin can carry, exactly as a coil does. + """ + storage = box.stored_outputs.get(pin) + if storage == "set": + return " =S> " + if storage == "reset": + return " =R> " + return " =o> " if pin in box.negated_outputs else " => " + + +def _pin_head(box, pin): + """The arrow head on a store hung off an output pin on its own wire. + + The same heads a coil carries: "o>" for the negation bubble, "(S)>" and + "(R)>" for a set and a reset. + """ + storage = box.stored_outputs.get(pin) + if storage == "set": + return "(S)> " + if storage == "reset": + return "(R)> " + return "o> " if pin in box.negated_outputs else "> " + + +def _pin_feed_symbols(expr): + """A side pin's contact feed as drawn contacts, e.g. "PowerOff |P|". + + The contact a reset or enable reads is shown as the contact it is - its + label and its symbol - wired into the pin, rather than flattened into a + caption like "R(PowerOff)". + """ + if isinstance(expr, Element) and expr.kind == CONTACT: + symbol, label = _symbol_and_label(expr) + return (label + " " + symbol) if label else symbol + if isinstance(expr, Series): + parts = [_pin_feed_symbols(item) for item in expr.items if not isinstance(item, Empty)] + return " ".join(part for part in parts if part) + if isinstance(expr, Parallel): + return "(" + " / ".join(_pin_feed_symbols(branch) for branch in expr.branches) + ")" + return "" + + +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() + + # The value feeding a side pin is drawn to the left of the box, on a wire + # into the pin, the way the editor draws it and the way the FBD renderer + # already does. Written inside as "PT := T#5S" it reads as part of the pin + # name, and it widens the box by the length of every value in it. + left = [] + wired = [] + values = [] + for pin, label in element.input_pins: + left.append(pin or "?") + # A label of None is the power pin - it is wired, not parameterised. + wired.append(label is None) + if pin in element.pin_feeds: + # A contact reset or enable, drawn as the contact it is. + values.append(_pin_feed_symbols(element.pin_feeds[pin])) + else: + values.append("" if label is None else (label or "")) + + # A store written on an output pin hangs off that pin on a wire of its + # own, as CODESYS draws it. Writing it inside the box put the target + # variable in among the pin names, where it reads as another pin. + right = [] + tails = [] + for index, pin_and_assignment in enumerate(element.output_pins): + pin, assigned = pin_and_assignment + text = pin or "?" + wired_out = element.output_wired and pin == element.active_output + tail = "" + if assigned and index > 0: + # Hung off the pin, as CODESYS draws it. Only below the first row: + # that one carries the rung's own wire onward to the rail, and a + # store sharing it would read as the rung running through it. + tail = chars["H"] * 3 + _pin_head(element, pin) + assigned + elif assigned: + text += _pin_arrow(element, pin) + 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) + tails.append(tail) + + rows = max(len(left), len(right), 1) + left += [""] * (rows - len(left)) + wired += [False] * (rows - len(wired)) + values += [""] * (rows - len(values)) + right += [""] * (rows - len(right)) + tails += [""] * (rows - len(tails)) + + title = element.title + # An EXECUTE box carries its inline ST as its body: the lines sit inside + # the box, below the pins, and widen it to the longest of them. Tabs are + # expanded to spaces so the box's right wall stays straight - a tab counts + # as one character but draws as several. + code = [line.expandtabs(4) for line in element.st_code] + inner = max( + [len(title)] + [len(left[i]) + 3 + len(right[i]) for i in range(rows)] + [len(line) + 2 for line in code] + ) + + # Two columns to the left of the box: the widest value, then a short wire + # into the pin. The power pin's row is all wire - the rung feeds that one. + lead = max([len(value) for value in values] + [0]) + lead = lead + 2 if lead else 0 + + def feed(index): + if not lead: + return "" + if wired[index]: + return chars["H"] * lead + value = values[index] + if not value: + return " " * lead + return value + chars["H"] * (lead - len(value)) + + lines = [" " * lead + centred(title, inner + 2)] + lines.append(" " * lead + chars["TL"] + chars["H"] * inner + chars["TR"]) + for index in range(rows): + gap = inner - len(left[index]) - len(right[index]) + # A pin fed from the left breaks the wall, whether the rung feeds it + # or a value does. A pin with nothing on it leaves the wall unbroken. + left_edge = chars["PIN_L"] if (wired[index] or values[index]) else chars["V"] + if wired[index] and element.power_edge in EDGE_MARKER: + # The P or N on the power pin, drawn on the box wall in the same + # place the bubble goes and the same letter a contact carries. + left_edge = EDGE_MARKER[element.power_edge] + elif wired[index] and element.power_negated: + # The negation bubble on the power pin, drawn on the box wall. + left_edge = "o" + elif left[index] in element.pin_marks: + # The bubble or P/N a side pin carries itself, on the wall after + # the contact that feeds it - the same place the power pin's goes. + negated, edge = element.pin_marks[left[index]] + left_edge = EDGE_MARKER[edge] if edge in EDGE_MARKER else "o" + # Only the active output continues onward, and only if consumed - but + # a pin with a store on it breaks the wall for that wire too. + onward = index == 0 and element.output_wired + right_edge = chars["PIN_R"] if (onward or tails[index]) else chars["V"] + if onward and element.active_output in element.negated_outputs: + right_edge = "o" + lines.append(feed(index) + left_edge + left[index] + " " * gap + right[index] + right_edge + tails[index]) + for line in code: + # A body line of an EXECUTE box, inside the box below the pins. + lines.append(" " * lead + chars["V"] + " " + line + " " * (inner - len(line) - 1) + chars["V"]) + lines.append(" " * lead + 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) + + +_SINK_KINDS = (COIL, "jump", "return", "outVariable") + + +def _ends_in_sink(expr): + """True when a rung path ends in a coil, a return or another output. + + Such a path runs to the right power rail on its own. A parallel of them - + two coils off one contact, or a return beside a coil - is a set of rung + ends, not one wire that rejoins and carries on. + """ + if isinstance(expr, Series): + return bool(expr.items) and _ends_in_sink(expr.items[-1]) + if isinstance(expr, Parallel): + return bool(expr.branches) and all(_ends_in_sink(branch) for branch in expr.branches) + if isinstance(expr, Element): + return expr.kind in _SINK_KINDS + return False + + +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 = [] + sink_rows = set() + for block in blocks: + width = block.width + above = connect_row - block.connect_row + lines = [" " * width] * above + # The wire row is padded with wire, not spaces: a box with a store + # hanging off a lower pin is wider than its own wire row, and padding + # that with spaces broke the rung in half. + lines += block.padded(width) + lines += [" " * width] * (height - len(lines)) + columns.append(lines) + # A block that ends in sinks running to the rail keeps those rows - + # shifted to where it now sits - so the rung can rail each of them. + for row in block.sink_rows: + sink_rows.add(above + row) + + joined = [] + for row in range(height): + joined.append("".join(column[row] for column in columns)) + return Block(joined, connect_row, sink_rows=sink_rows) + + +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 = [] + nested_sinks = set() + for block in blocks: + offset = len(stacked) + connect_rows.append(offset + block.connect_row) + nested_sinks.update(offset + row for row in block.sink_rows) + 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. + # A rung end inside a branch is a wire too, running to the rail. + fill = chars["H"] if index == block.connect_row or index in block.sink_rows else " " + stacked.append(line + fill * (width - len(line))) + + junctions = set(connect_rows) + first, last = connect_rows[0], connect_rows[-1] + + # When every branch ends in a sink, each runs to the right rail on its own; + # the branch splits on the left but never rejoins on the right. + terminal = all(_ends_in_sink(branch) for branch in branches) + + 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"] + 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) if terminal else (left + line + right)) + + # A branch that splits again into rung ends keeps them: each still runs to + # the rail on its own. + sink_rows = (set(connect_rows) | nested_sinks) if terminal else set() + return Block(lines, first, sink_rows=sink_rows) + + +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 _identity(expr): + """Which elements an expression is made of, so copies of one node can be spotted. + + The parser builds one branch per sink, so a node feeding several sinks + arrives as one copy per branch, and those copies are one element in the + editor. What an element draws does not decide this: two contacts on one + variable, or two EXECUTE boxes with one body, draw the same and are still + two elements. Treating them as one deleted the second coil of a double coil. + So the key is the node's localId - and, for a box, the pin it is read + through, since a box read through two pins does not continue one wire. + """ + if isinstance(expr, Series): + return ("series",) + tuple(_identity(item) for item in expr.items) + if isinstance(expr, Parallel): + return ("parallel",) + tuple(_identity(branch) for branch in expr.branches) + if isinstance(expr, Element): + if expr.local_id is None: + # Built from no node, such as a cycle marker: it is only itself. + return ("element", id(expr)) + return ("element", expr.kind, expr.local_id, expr.active_output, expr.label) + return ("empty",) + + +def _factor(expr): + """Pull the leading elements shared by every parallel branch out in front. + + The parser builds one branch per sink, so a contact chain or a block that + feeds several sinks is repeated in each branch - drawn again and again, + which reads as separate rungs rather than one wire that branches. CODESYS + draws the shared part once and splits after it; factoring the common + prefix of the branches produces exactly that. Power flow is unchanged: + "(P AND a) OR (P AND b)" and "P AND (a OR b)" drive the same rung. + + Only copies of one node are pulled out. Branches that begin with the same + node are factored even when others beside them do not, as long as they + are next to each other: one contact feeding two boxes is one contact, and + keeping the branches in their order keeps the rung ends in the order they + execute. + """ + if isinstance(expr, Series): + return series([_factor(item) for item in expr.items]) + if isinstance(expr, Parallel): + branches = [_factor(branch) for branch in expr.branches] + runs = [] + for branch in branches: + items = _items_of(branch) + head = _identity(items[0]) if items else None + if runs and head is not None and runs[-1][0] == head: + runs[-1][1].append(branch) + else: + runs.append((head, [branch])) + if len(runs) == 1: + return _factor_run(branches) + return parallel([_factor_run(run) for _head, run in runs]) + return expr + + +def _items_of(branch): + """The elements along a branch. An empty branch has none, so no head to share.""" + if isinstance(branch, Empty): + return [] + return list(branch.items) if isinstance(branch, Series) else [branch] + + +def _factor_run(branches): + """Pull the leading elements every one of ``branches`` shares out in front.""" + if len(branches) == 1: + return branches[0] + parts = [_items_of(branch) for branch in branches] + prefix = [] + while all(part for part in parts): + first = parts[0][0] + identity = _identity(first) + if any(_identity(part[0]) != identity for part in parts): + break + prefix.append(first) + parts = [part[1:] for part in parts] + if not prefix: + return parallel(branches) + if not any(parts): + # Every branch was the same wire - one pin listing one source twice. + return series(prefix) + # What follows the shared head is a parallel of its own, and some of its + # branches can share a head again. The prefix is not empty, so this works + # on fewer elements each time and ends. + return series(prefix + [_factor(parallel([series(part) for part in parts]))]) + + +def _pin_block_rungs(expr, found): + """Collect the sub-rungs feeding side pins, in the order they execute. + + A box wired into another box's side pin is drawn on a wire of its own + above the box that reads it, which names it in its pin caption. Deeper + boxes come first, because that is the order the values are produced in. + """ + if isinstance(expr, Series): + for item in expr.items: + _pin_block_rungs(item, found) + elif isinstance(expr, Parallel): + for branch in expr.branches: + _pin_block_rungs(branch, found) + elif isinstance(expr, Element): + for pin_block in expr.pin_blocks: + _pin_block_rungs(pin_block, found) + found.append(pin_block) + return found + + +def _render_wire(expr): + """One wire between the 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"]) + elif row in block.sink_rows: + # A lower branch that ends in a coil or a return: the left rail runs + # past it, and it reaches the right rail on its own. + lines.append(chars["V"] + " " + line + chars["H"] * 2 + chars["T_LEFT"]) + else: + lines.append(chars["V"] + " " + line) + return lines + + +def _block_prefix_key(rung): + """The identity of a rung's head up to and including its first block. + + Two rungs with the same key begin with the same chain into the same box - + the same nodes, not merely nodes that draw alike. + A box read by several sinks is one box that runs once, so those rungs are + the branches of one wire that splits after it; grouping them by this key + lets the split be drawn once. A rung with no block returns None and is + never merged - rungs that share only leading contacts may be separate + rungs the editor keeps apart, and fusing them would misread the program. + """ + items = rung.items if isinstance(rung, Series) else [rung] + prefix = [] + for item in items: + prefix.append(item) + if isinstance(item, Element) and item.kind == BLOCK: + return tuple(_identity(part) for part in prefix) + return None + + +def _merge_rungs(rungs): + """Combine rungs that split after a shared box into one branched rung. + + Rungs sharing a block are gathered into a Parallel, in the order they + first appear; _factor then pulls the common head (the chain and the box) + out in front so the box is drawn once and the readers branch off it. + Everything else is left exactly as it was. + """ + order = [] + groups = {} + for rung in rungs: + key = _block_prefix_key(rung) + marker = key if key is not None else object() + if marker not in groups: + groups[marker] = [] + order.append(marker) + groups[marker].append(rung) + + merged = [] + for marker in order: + group = groups[marker] + merged.append(group[0] if len(group) == 1 else parallel(group)) + return merged + + +def _box_key(item): + """(localId, pin) for a copy of a box with no instance name, else None. + + A box with an instance is never built twice: every reader after the first + already names it. + """ + if isinstance(item, Element) and item.kind == BLOCK and item.local_id is not None and not item.instance_name: + return (item.local_id, item.active_output) + return None + + +def _box_keys(expr, found): + """Every box key on the wires of ``expr``, in drawing order, repeats kept. + + The boxes hoisted into a box's side pins are drawn as wires of their own, + so they are not on this wire and are not counted here. + """ + if isinstance(expr, Series): + for item in expr.items: + _box_keys(item, found) + elif isinstance(expr, Parallel): + for branch in expr.branches: + _box_keys(branch, found) + else: + key = _box_key(expr) + if key is not None: + found.append(key) + return found + + +def _reference_to(box): + """The pin of a box drawn elsewhere, named where a copy of the box would be.""" + pin = box.active_output + base = box_name(box) + return Element( + kind=IN_VARIABLE, + label=(base + "." + pin) if pin else base, + negated=pin in box.negated_outputs, + local_id=box.local_id, + ) + + +def _keep_first(expr, repeat): + """``expr`` with every copy of a box in ``repeat`` but the first built replaced. + + The first copy the parser built is kept, wherever it is drawn: only that + copy carries the instance boxes upstream of the box, because each later + build finds them built and names them. Keeping any other copy deleted + them. A replaced copy takes the wire that powers it with it: that wire is + the input of the box, drawn once with the box, and left in front of the + name it would state a condition the program does not have. + """ + if isinstance(expr, Series): + out = [] + produced = [] + for index, item in enumerate(expr.items): + key = _box_key(item) + if key is not None and key in repeat and item.copy: + power = min(item.power_len, index) + dropped = sum(produced[index - power : index]) + if dropped: + del out[len(out) - dropped :] + for position in range(index - power, index): + produced[position] = 0 + out.append(_reference_to(item)) + produced.append(1) + continue + out.append(_keep_first(item, repeat)) + produced.append(1) + return series(out) + if isinstance(expr, Parallel): + return parallel([_keep_first(branch, repeat) for branch in expr.branches]) + key = _box_key(expr) + if key is not None and key in repeat and expr.copy: + return _reference_to(expr) + return expr + + +def _last_box(wire): + """The box a hoisted wire ends in: the one the side pin reads.""" + return wire.items[-1] if isinstance(wire, Series) else wire + + +def _drawn_twice(rungs, repeat): + """The boxes the rungs would draw more than once, with ``repeat`` named. + + Counted over the whole network, on the rungs as laid out and on every + hoisted wire the rungs draw: copies that the rung merge or the + shared-prefix factoring draw as one box count once. + """ + counts = {} + for rung in rungs: + rung = _keep_first(rung, repeat) + wires = [_factor(rung)] + for pin_block in _pin_block_rungs(rung, []): + last = _last_box(pin_block) + if not (_box_key(last) in repeat and last.copy): + wires.append(pin_block) + for wire in wires: + for key in _box_keys(wire, []): + counts[key] = counts.get(key, 0) + 1 + return set(key for key in counts if counts[key] > 1) + + +def _network_repeats(rungs): + """The boxes to name rather than draw again, for one network. + + Naming a copy takes its wire with it, and that can stop the copies of the + box after it from sharing a head, so they are drawn twice in turn. The set + is widened until naming it leaves nothing drawn twice. It only grows, and + there are finitely many boxes, so this ends. + """ + repeat = set() + while True: + wider = repeat | _drawn_twice(rungs, repeat) + if wider == repeat: + return repeat + repeat = wider + + +def render_rung(expr, repeat=None): + """Render one rung, bounded by the power rails. + + Boxes feeding side pins are drawn first, on wires of their own: the + caption that reads one names only its output, so without the box the + diagram would not say what feeds it. An EXECUTE box's inline ST is drawn + inside the box, below its pins, by _render_block. + + ``repeat`` holds the boxes the network would otherwise draw more than once + (see _network_repeats). Every copy of one but the first built is named by + its pin, so one box in the editor is one box on the page. + """ + if repeat is None: + repeat = _network_repeats([expr]) + lines = [] + for pin_block in _pin_block_rungs(expr, []): + # Every reader of a hoisted box carries the hoist; the first built + # draws the box, and the pin's caption names it for the rest. + last = _last_box(pin_block) + if _box_key(last) in repeat and last.copy: + continue + lines.extend(_render_wire(_keep_first(pin_block, repeat))) + # Draw the shared head of parallel branches once, then the split - the way + # the editor draws it - instead of repeating it down every branch. + lines.extend(_render_wire(_factor(_keep_first(expr, repeat)))) + 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") + + # The rebuilt form is not what CODESYS holds: the structured interface has + # nowhere to put a comment, a pragma or an attribute, and a variable whose + # type the export omits comes back as UNKNOWN. The summary says how many + # POUs this happened to; the file has to say that it is one of them. + keyword = POU_TYPE_KEYWORDS.get(pou.pou_type, "PROGRAM") + lines = [ + "(* Declaration rebuilt from the structured interface:" + " comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *)", + 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 the rungs of each network. + + A network can hold more than one rung - a block driving three outputs is + one network in the editor - so the number belongs to the network, not to + the rung. + """ + lines = render_declaration(pou) + lines.append("") + + if not pou.networks: + lines.append("(* no rungs *)") + + for index, network in enumerate(pou.networks): + lines.extend(network_headers(index + 1, network)) + rungs = _merge_rungs(network.outputs) + repeat = _network_repeats(rungs) + for rung in rungs: + lines.extend(render_rung(rung, repeat)) + 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..dd193ce --- /dev/null +++ b/src/model.py @@ -0,0 +1,663 @@ +# 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" +# Carries no logic, but CODESYS writes one above each network that has a +# comment, so it marks where one network begins. +COMMENT = "comment" +# The network's title, exported as a vendorElement. Also a header, also a +# boundary, and often the only description a network carries. +TITLE = "networktitle" + +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. + + ``edge`` is the P or N CODESYS draws on the pin: the pin sees a single + scan when its value changes, not the value itself. A block behind one + runs once per edge, and reads as running every cycle without it. + """ + + def __init__(self, ref_id, source_pin=None, target_pin=None, negated=False, edge=None): + self.ref_id = ref_id + self.source_pin = source_pin + self.target_pin = target_pin + self.negated = negated + self.edge = edge # "rising" | "falling" | None + + 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, + stored_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() + # blocks only: {pin: "set" | "reset"} for inline assignments that store + self.stored_outputs = dict(stored_outputs) if stored_outputs is not None else {} + # blocks only: set by the network builder; see box_name + self.ordinal = None + + def __repr__(self): + return "Node(%s, %s, %r, inputs=%r)" % (self.local_id, self.kind, self.label, self.inputs) + + +def box_name(box): + """The name a box is written under when the text names it instead of a wire. + + An instance has its own name. A box without one - an operator, or an + EXECUTE box - is named by its type, and two such boxes of one type in one + network would then be indistinguishable: rewiring a coil from one to the + other would change nothing in the export. So where a network holds two or + more of them and one is named, each carries an ordinal, printed after its + type in its title and in every reference to it. + """ + if box.instance_name: + return box.instance_name + base = box.type_name or "?" + if box.ordinal: + return "%s#%d" % (base, box.ordinal) + return base + + +def _numbered_type(box): + """A box title's type part: 'ADD', or 'ADD #2' when it carries an ordinal.""" + base = box.type_name or "?" + if box.ordinal: + return "%s #%d" % (base, box.ordinal) + return base + + +def component_finder(nodes): + """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. Connections to elements + outside ``nodes`` are ignored, which is how a caller keeps a shared + anchor - an LD power rail - from fusing every network into one. + """ + parent = {} + for node in nodes: + 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 nodes: + 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 label_name(tree): + """The jump target a parsed label carries, or None for anything else. + + FBD parses a label into a Label; LD parses it into an Element whose kind + says label. The two are different classes, and treating only one of them + as a label leaves the other in the network's body. + """ + if isinstance(tree, Label): + return tree.name + if isinstance(tree, Element) and tree.kind == LABEL: + return tree.label + return None + + +def _network(header, trees): + """(comment, title, label, outputs) - the label lifted out of the trees. + + CODESYS keeps one label per network, so a second one cannot come from an + export; should one arrive it stays in the body, visible, rather than + being dropped. + """ + label = "" + outputs = [] + for tree in trees: + name = label_name(tree) + if name is not None and not label: + label = name + else: + outputs.append(tree) + return (header[0] or "", header[1] or "", label, outputs) + + +def assemble_networks(nodes, root_of, outputs_by_root, label_roots=()): + """Order the components of a body into networks, in editor order. + + Returns [(comment, title, label, [outputs])]. Three things decide where + a network begins and what it is called: + + * A comment or a title element is a header, and heads the network whose + elements follow it. A second one of either means the header before it + headed a network of its own - a network holding nothing but + documentation. Dropping those silently renumbered every network after + them, so a reviewer opening "Network 5" in CODESYS read different logic + under "(* Network 5 *)" in the file. + * A header is not a reliable boundary on its own: CODESYS writes no + comment element for a network that has none. Connectivity is what + separates networks; the header only names the one it precedes. + * A jump label is stored on the network in CODESYS but exported as a + free-standing element just before it, so it arrives as a component of + its own. It belongs to the network that follows it - as that network's + label, not as part of its body. Left in the body it was drawn as a + rung, and twice over once the native export supplied the label too. + """ + label_roots = set(label_roots) + networks = [] + header = [None, None] + carried = [] + seen = set() + + for node in nodes: + if node.kind in (COMMENT, TITLE): + index = 0 if node.kind == COMMENT else 1 + # A header comes before its network's label, so one arriving after a + # carried label heads the next network: the label's ends here. + carries_label = any(label_name(tree) is not None for tree in carried) + if header[index] is not None or carries_label: + networks.append(_network(header, list(carried))) + del carried[:] + header = [None, None] + header[index] = node.label or "" + continue + + root = root_of(node) + if root is None or root in seen or root not in outputs_by_root: + continue + seen.add(root) + if root in label_roots: + # A network has one label. A second arriving before any logic means + # the first labelled a network with none, which ends here - carried + # on, the second label was left in the next network's body. + if any(label_name(tree) is not None for tree in carried): + networks.append(_network(header, list(carried))) + del carried[:] + header = [None, None] + carried.extend(outputs_by_root[root]) + continue + + networks.append(_network(header, carried + outputs_by_root[root])) + del carried[:] + header = [None, None] + + if header[0] is not None or header[1] is not None or carried: + networks.append(_network(header, list(carried))) + return networks + + +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. ``networks`` holds one entry per network in the editor. + + Both languages fill it: for FBD each network holds the trees driving its + outputs, for LD the rungs of that network. A network can hold more than + one of either - a block driving three outputs is one network in the + editor, and numbering it as three throws every later number out. + """ + + def __init__(self, name, pou_type, variables=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.networks = networks if networks is not None else [] + + @property + def rungs(self): + """Every LD rung in the POU, network grouping flattened away.""" + return [tree for network in self.networks for tree in network.outputs] + + +# --- 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. + + +# How an edge-triggered pin reads. The same spelling the LD contacts use, so +# both languages grep alike. +EDGE_FUNCTION = {"rising": "R", "falling": "F"} + + +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. ``edge`` is the pin's own P or N: the + bubble inverts what arrives, and the edge detector then sees that value + change, so the negation goes inside. + """ + + def __init__(self, label, negated=False, edge=None): + self.label = label + self.negated = negated + self.edge = edge + + @property + def text(self): + label = self.label or "" + if self.negated: + # A compound expression must keep its parentheses or the logic + # regroups - see is_simple_term for the precedence trap. + label = ("NOT " + label) if is_simple_term(label) else ("NOT (" + label + ")") + function = EDGE_FUNCTION.get(self.edge) + if function: + return function + "(" + label + ")" + return label + + def __repr__(self): + return "Signal(%r, negated=%r, edge=%r)" % (self.label, self.negated, self.edge) + + +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, + wired_outputs=None, + st_code=None, + negated_outputs=None, + stored_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 [] + # The pin a reader gets when it does not name one. Which pin a given + # reader takes lives on the reader, in an OutputRef. + if active_output is None and self.outputs: + active_output = self.outputs[0][0] + 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() + # {pin: "set" | "reset"} for inline assignments that store instead of + # assigning outright. + self.stored_outputs = dict(stored_outputs) if stored_outputs is not None else {} + # 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 [] + # Every output pin something downstream reads. A network sink reads + # none; a block read through two of its pins has two, and is still one + # box, called once. + self.wired_outputs = set(wired_outputs) if wired_outputs is not None else set() + # Set by the renderer; see box_name. + self.ordinal = None + + @property + def output_wired(self): + """True when anything downstream reads an output of this call.""" + return bool(self.wired_outputs) + + @property + def title(self): + if self.instance_name: + return self.instance_name + " : " + (self.type_name or "?") + return _numbered_type(self) + + @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 OutputRef(object): + """The value on one output pin of a Call, as read by its consumer. + + The pin belongs to the wire, not to the box. Holding it on the Call meant + a block read through two pins was two Calls: drawn twice, and called twice + in the ST, so a reader concluded a stateful block ran twice per cycle. + """ + + def __init__(self, call, pin): + self.call = call + self.pin = pin + + def __repr__(self): + return "OutputRef(%r, %r)" % (self.call, self.pin) + + +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, title="", label="", note=None): + self.comment = comment + # CODESYS keeps a network's title separately from its comment, and + # draws it above one. A network can carry either, both or neither. + self.title = title + # The jump-target label CODESYS keeps on the network. PLCopen writes + # it as a loose element just before the network's body, and the + # parser lifts it back here; the native export carries it as the + # property it is, and that one is taken in preference. + self.label = label + # Why this network has no body: out-commented, or empty. Set only for + # networks the PLCopen export left out entirely. + self.note = note + self.outputs = outputs if outputs is not None else [] + + def __repr__(self): + return "Network(%r, %r, %d outputs)" % (self.comment, self.title, 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, storage=None): + self.label = label + self.source = source + self.negated = negated + # "set" | "reset" | None. A stored value is held until something + # resets it; rendering one as a plain assignment says it clears as + # soon as its condition drops, which is the opposite of the program. + self.storage = storage + + def __repr__(self): + return "Assign(%r, negated=%r, storage=%r)" % (self.label, self.negated, self.storage) + + +# --- 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, + power_edge=None, + negated_outputs=None, + stored_outputs=None, + pin_blocks=None, + st_code=None, + pin_feeds=None, + pin_marks=None, + local_id=None, + ordinal=None, + power_len=0, + copy=0, + ): + self.kind = kind + # Blocks only: 0 for the first copy of the box the parser built, 1 for + # the next, and so on. Only the first carries the instance boxes + # upstream of it; a later copy names them. See ld_render._keep_first. + self.copy = copy + # Blocks only; see box_name. + self.ordinal = ordinal + # Blocks only: how many items directly before the box in its rung are + # the wire that powers it. A later copy of the box is replaced by a + # reference, and that wire goes with it, since it belongs to the box. + self.power_len = power_len + # The localId of the PLCopen node this element was built from. The + # parser builds one branch per sink, so one node can arrive as several + # copies; this is what says they are one element in the editor. Two + # nodes that draw the same are still two elements. + self.local_id = local_id + 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 + # The P or N on that same pin: the block runs once per edge, not once + # per scan the condition holds. + self.power_edge = power_edge + self.negated_outputs = negated_outputs if negated_outputs is not None else set() + # {pin: "set" | "reset"} for inline assignments that store instead of + # assigning outright. + self.stored_outputs = dict(stored_outputs) if stored_outputs is not None else {} + # 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 + # Blocks only: whole sub-rungs feeding this block's side pins. A box + # wired into a side pin has a call of its own to make, with its own + # inputs; the pin caption only names the output it reads. They are + # rendered and emitted before this block, in the order the pins were + # wired, because that is the order they execute in. + self.pin_blocks = pin_blocks if pin_blocks is not None else [] + # An EXECUTE box carries inline ST as its whole body. Drawing the box + # without it leaves an empty rectangle where the logic should be. + self.st_code = st_code if st_code is not None else [] + # Blocks only: {pin: expression} for a side pin fed by a contact chain. + # A contact reset or enable is drawn as the contact it is, wired into + # the pin, rather than flattened into the pin caption as text. + self.pin_feeds = pin_feeds if pin_feeds is not None else {} + # Blocks only: {pin: (negated, edge)} for a drawn-contact side pin that + # carries a bubble or a P/N of its own. The caption form spells those + # out in its text; a drawn contact needs them put on the box wall. + self.pin_marks = pin_marks if pin_marks is not None else {} + + @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 "?") + if self.type_name: + return _numbered_type(self) + return 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/native_networks.py b/src/native_networks.py new file mode 100644 index 0000000..12e3dc2 --- /dev/null +++ b/src/native_networks.py @@ -0,0 +1,164 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Number the rendered networks the way the CODESYS editor numbers them. + +The rendering is derived from a PLCopen export, but PLCopen is not what a +reviewer holds next to it - the native xml is, and the two disagree about +what a network is. CODESYS omits from PLCopen every network that carries no +elements: an out-commented one (Toggle Network Comment State) goes entirely, +comment included, and so does an empty one. Numbering what survives 1..n +therefore drifts from what the editor shows, silently, and a reviewer opening +"Network 5" in CODESYS reads different logic under "(* Network 5 *)". + +The native export, which codescribe writes immediately before rendering, +carries the full list: every network in editor order with its out-commented +flag, its comment, its title, its label and its items. That list is the +authority for the *structure* - how many networks there are, in what order, +and what each is called. PLCopen supplies only the logic, and the two are +joined by matching networks that carry logic, in order. + +That is the whole of the alignment, and it is deliberately not a positional +match over both lists. Matching position for position means guessing which +side an absent network belongs to, and getting it wrong silently; matching +only the networks that both sides agree exist leaves nothing to guess. + +The native format is proprietary and undocumented, so everything here is +best-effort: any surprise degrades to None and the caller falls back to +sequential numbering, saying so in the file. +""" + +import os + +from model import Network +import plcopen +import xmlbackend + +# Said in the file itself, under the number the network occupies. A reviewer +# reading only the .txt has to learn that logic exists here without +# executing, which is the fact the silent drop hid. +NOTE_OUT_COMMENTED = "out-commented in CODESYS - does not execute; diagram not exported, see the native xml" +NOTE_EMPTY = "empty network" + + +class NativeNetwork(object): + """One network as the native export records it.""" + + def __init__(self, out_commented=False, empty=False, comment="", title="", label=""): + self.out_commented = out_commented + self.empty = empty + self.comment = comment + self.title = title + # The jump-target label CODESYS stores on the network itself. The + # parser lifts the free-standing element PLCopen writes into the + # network it precedes; this one is the editor's own, and is taken + # in preference. + self.label = label + + @property + def has_logic(self): + """True when this network has a body PLCopen would have exported.""" + return not self.out_commented and not self.empty + + def __repr__(self): + return "NativeNetwork(out_commented=%r, empty=%r, comment=%r, title=%r, label=%r)" % ( + self.out_commented, + self.empty, + self.comment, + self.title, + self.label, + ) + + +def read_networks(path): + """[NativeNetwork] in editor order, or None if the file yields none. + + Networks live under ; each entry carries its + flags as children. Entries without an OutCommented + flag are not networks and are skipped. None rather than [] on any + trouble: the caller treats None as "no authority available" and keeps the + sequential numbering. + """ + try: + if not os.path.exists(path): + return None + root = xmlbackend.parse(plcopen.read_document(path)) + except Exception: + return None + + networks = [] + for elem in root.iter(): + if plcopen.tag(elem) != "List2" or elem.get("Name") != "NetworkList": + continue + for net in elem: + out_commented = None + comment = "" + title = "" + label = "" + items_elem = None + for child in net: + tag_name, name = plcopen.tag(child), child.get("Name") + if tag_name == "Single" and name == "OutCommented": + out_commented = (child.text or "").strip() == "True" + elif tag_name == "Single" and name == "Comment": + comment = (child.text or "").strip() + elif tag_name == "Single" and name == "Title": + title = (child.text or "").strip() + elif tag_name == "Single" and name == "Label": + label = (child.text or "").strip() + elif tag_name == "List2" and name == "NetworkItems": + items_elem = child + if out_commented is None: + continue + # The .NET element wrapper has no __len__, so emptiness is probed + # by iterating. + empty = items_elem is None or not any(True for _ in items_elem) + networks.append( + NativeNetwork(out_commented=out_commented, empty=empty, comment=comment, title=title, label=label) + ) + return networks or None + + +def _carries_logic(network): + """True for a parsed network that PLCopen exported a body for. + + A network the parser built from a comment element alone has no outputs, + and neither has one built from a jump label alone: the parser lifts the + label onto the network, where CODESYS keeps it. Neither is something the + native list has a body for, so neither takes part in the match. + """ + return bool(getattr(network, "outputs", [])) + + +def align(native, parsed): + """[Network] numbered as the editor numbers them, or None. + + One entry per native network, in editor order, carrying that network's + own comment, title and label. The networks that carry logic take their + bodies from the parsed list in order; the rest render as a placeholder + saying why they have none. + + None when the two disagree about how many networks carry logic. That + means an assumption broke - a body split in two, or a CODESYS that does + export out-commented networks after all - and misnumbering silently is + worse than saying so. + """ + if not native: + return None + + bodies = [network for network in parsed if _carries_logic(network)] + if len(bodies) != len([network for network in native if network.has_logic]): + return None + + aligned = [] + index = 0 + for entry in native: + outputs = [] + note = None + if entry.has_logic: + outputs = bodies[index].outputs + index += 1 + elif entry.out_commented: + note = NOTE_OUT_COMMENTED + else: + note = NOTE_EMPTY + aligned.append(Network(comment=entry.comment, outputs=outputs, title=entry.title, label=entry.label, note=note)) + return aligned diff --git a/src/parse_fbd.py b/src/parse_fbd.py new file mode 100644 index 0000000..eae954c --- /dev/null +++ b/src/parse_fbd.py @@ -0,0 +1,316 @@ +# 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, + COMMENT, + EDGE_FUNCTION, + TITLE, + Assign, + Call, + Jump, + Label, + Network, + Node, + OutputRef, + Pou, + Signal, + assemble_networks, + component_finder, +) +from plcopen import ( + attr, + block_connections, + block_outputs, + block_st_code, + child_text, + comment_text, + declaration_text, + direct_connections, + find_child, + is_true, + iter_bodies, + negated_output_pins, + network_title, + parse_interface, + stored_output_pins, + tag, +) + +IN_VARIABLE = "inVariable" +OUT_VARIABLE = "outVariable" +JUMP = "jump" +LABEL = "label" +RETURN = "return" +CONNECTOR = "connector" +CONTINUATION = "continuation" + +VENDOR_ELEMENT = "vendorElement" + +# A vendorElement carries CODESYS editor state and mostly holds no logic - but +# a network title is one, and a title heads a network just as a comment does. +FBD_KINDS = ( + BLOCK, + IN_VARIABLE, + OUT_VARIABLE, + COMMENT, + JUMP, + RETURN, + LABEL, + CONTINUATION, + CONNECTOR, + VENDOR_ELEMENT, +) + +# 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 + + if kind == VENDOR_ELEMENT: + title = network_title(child) + if title is not None: + nodes.append(Node(local_id=local_id, kind=TITLE, label=title)) + 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"), + # A store held until something resets it, on an outVariable + # exactly as on an LD coil. + 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, + st_code=block_st_code(child) if is_block else None, + negated_outputs=negated_output_pins(child) if is_block else None, + stored_outputs=stored_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", + wired_outputs=["Out"], + ) + + +def _edge(source, edge): + """Wrap a pin's source in the edge detection its P or N marker demands. + + A Signal carries the marker itself; anything else becomes an explicit R + or F box, the same shape _negate uses, so the trigger is visible in both + the ST and the diagram rather than reading as a plain level. + """ + function = EDGE_FUNCTION.get(edge) + if function is None: + return source + if isinstance(source, Signal): + return Signal(source.label, negated=source.negated, edge=edge) + return Call( + type_name=function, + inputs=[("In", source)], + outputs=[("Out", None)], + active_output="Out", + wired_outputs=["Out"], + ) + + +def _build(node, by_id, visiting, via_pin=None, memo=None): + """Build the tree feeding a node, as read through ``via_pin``. + + Memoised on the localId alone, so every reader of an element gets the very + same object - which is what lets the renderers draw one box with a branch + instead of two identical boxes, and lets the ST emit one call. Keying on + the pin as well made a block read through two of its pins into two blocks. + The pin is instead recorded on the wire, as an OutputRef, and remembered + on the call so the renderer knows which pins to break the box wall for. + """ + if memo is None: + memo = {} + if node.local_id not in memo: + memo[node.local_id] = _build_node(node, by_id, visiting, memo) + built = memo[node.local_id] + if via_pin is not None and isinstance(built, Call): + built.wired_outputs.add(via_pin) + return OutputRef(built, via_pin) + return built + + +def _build_node(node, by_id, visiting, 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) + if connection.edge and source is not None: + # The P or N on that same pin, applied after the bubble: the + # detector sees the value the pin actually receives. + source = _edge(source, connection.edge) + inputs.append((connection.target_pin, source)) + + return Call( + type_name=node.type_name, + instance_name=node.instance_name, + inputs=inputs, + outputs=list(node.outputs), + st_code=list(node.st_code), + negated_outputs=set(node.negated_outputs), + stored_outputs=node.stored_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, storage=node.storage) + 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 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 not in (COMMENT, TITLE)] + + 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) + + # Shared upstream nodes must come back as the same object, so the + # renderers can tell a fan-out from two coincidentally equal expressions. + memo = {} + outputs_by_root = {} + for node in logic: + if node.local_id in consumed or node.kind not in SINK_KINDS: + continue + outputs_by_root.setdefault(find(node.local_id), []).append(_build(node, by_id, set(), None, memo)) + + # A component that is nothing but a jump label is the label of the network + # that follows it, not a network of its own. + label_roots = set() + for root, outputs in outputs_by_root.items(): + if outputs and all(isinstance(tree, Label) for tree in outputs): + label_roots.add(root) + + def root_of(node): + return find(node.local_id) if node.local_id in by_id else None + + return [ + Network(comment=comment, title=title, label=label, outputs=outputs) + for comment, title, label, outputs in assemble_networks(nodes, root_of, outputs_by_root, label_roots) + ] + + +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..6dc7a2c --- /dev/null +++ b/src/parse_ld.py @@ -0,0 +1,647 @@ +# 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, + COIL, + COMMENT, + CONTACT, + EDGE_FUNCTION, + IN_VARIABLE, + JUMP, + LABEL, + LEFT_RAIL, + RAILS, + RETURN, + RIGHT_RAIL, + TITLE, + Element, + Empty, + Network, + Node, + Parallel, + Pou, + Series, + assemble_networks, + box_name, + component_finder, + is_simple_term, + parallel, + series, +) +from plcopen import ( + attr, + block_connections, + block_outputs, + block_st_code, + child_text, + comment_text, + declaration_text, + direct_connections, + find_child, + is_true, + iter_bodies, + negated_output_pins, + network_title, + parse_interface, + stored_output_pins, + tag, +) + +VENDOR_ELEMENT = "vendorElement" + +# Elements that carry logic. Rails are structural: they anchor a rung but draw +# nothing themselves; a comment and a network title carry no logic either, but +# both head the network they precede. +KNOWN_KINDS = ( + LEFT_RAIL, + RIGHT_RAIL, + CONTACT, + COIL, + BLOCK, + "inVariable", + "outVariable", + JUMP, + RETURN, + LABEL, + COMMENT, + VENDOR_ELEMENT, +) + + +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 + + if kind == COMMENT: + # No logic of its own, but CODESYS writes one above each network + # that has a comment, and it names the network that follows it. + nodes.append(Node(local_id=local_id, kind=COMMENT, label=comment_text(child))) + continue + + if kind == VENDOR_ELEMENT: + title = network_title(child) + if title is not None: + nodes.append(Node(local_id=local_id, kind=TITLE, label=title)) + 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, + st_code=block_st_code(child) if is_block else None, + negated_outputs=negated_output_pins(child) if is_block else None, + stored_outputs=stored_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, + local_id=node.local_id, + ) + + +# While the networks are built: the set of localIds of boxes with no instance +# name that a caption has named, wherever in the caption they sit. None at any +# other time, such as when the ST renderer flattens an expression. +_naming = None + + +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 = box_name(expr) + if _naming is not None and not expr.instance_name and expr.local_id is not None: + _naming.add(expr.local_id) + 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 _block_reference(node, via_pin): + """A box already drawn in this network, named by the output being read. + + A block driving three outputs is one box that runs once. Rebuilding it for + every output drew it three times and called it three times, which reads as + three timers where the program has one; every reader after the first names + the pin it takes instead. Only an instance can be named this way - an + operator has no name, so its caller redraws it rather than reach here. + """ + pin = via_pin + if pin is None and node.outputs: + pin = node.outputs[0][0] + base = box_name(node) + return Element( + kind=IN_VARIABLE, + label=(base + "." + pin) if pin else base, + negated=pin in node.negated_outputs, + local_id=node.local_id, + ) + + +def _is_contact_chain(expr): + """True when an expression is nothing but contacts (and bare wire). + + A side pin fed by one is drawn as those contacts, wired into the pin; a + pin fed by a literal, a box or anything else keeps its flattened caption. + """ + if isinstance(expr, Element): + return expr.kind == CONTACT + if isinstance(expr, Series): + parts = [item for item in expr.items if not isinstance(item, Empty)] + return bool(parts) and all(_is_contact_chain(item) for item in parts) + if isinstance(expr, Parallel): + return bool(expr.branches) and all(_is_contact_chain(branch) for branch in expr.branches) + return False + + +def _build_block(node, by_id, visiting, via_pin, drawn, consumed=None): + """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. + """ + read_pin = via_pin + if read_pin is None and node.outputs: + read_pin = node.outputs[0][0] + if node.local_id in drawn.pins: + # Drawn once already. A stateful function block is one box that runs + # once, so the next reader names the pin it takes. A stateless + # operator has no instance to name - "OR.Out1" points at no variable - + # so a reader of the same pin redraws it, and the rung merge then draws + # the copies as one box. A reader of a different pin cannot merge: the + # copies differ in the pin the wire leaves by, and a redraw is a second + # box where the editor has one. That reader names the pin instead, and + # box_name numbers the box if its type alone would not say which. + if node.instance_name or drawn.pins[node.local_id] != read_pin: + if not node.instance_name: + drawn.named.add(node.local_id) + return _block_reference(node, via_pin) + drawn.copied.add(node.local_id) + else: + drawn.order[node.local_id] = len(drawn.order) + drawn.pins[node.local_id] = read_pin + copy = drawn.builds.get(node.local_id, 0) + drawn.builds[node.local_id] = copy + 1 + + power_expr = Empty() + power_pin = None + power_negated = False + power_edge = None + side_pins = [] + pin_blocks = [] + pin_feeds = {} + pin_marks = {} + + # Several connections landing on one pin are a wired OR into that pin - + # the same several--under-one-connectionPointIn shape a coil + # or a contact collects into a Parallel. Grouped by pin so those branches + # feed the pin as one OR, rather than being spread across duplicate + # captions ("IN := xStart, IN := xRun") that lose the OR and read as two + # separate pins. + order = [] + grouped = {} + for connection in node.inputs: + if connection.target_pin not in grouped: + grouped[connection.target_pin] = [] + order.append(connection.target_pin) + grouped[connection.target_pin].append(connection) + + for pin in order: + connections = grouped[pin] + branches = [] + all_from_variables = True + for connection in connections: + upstream = by_id.get(connection.ref_id) + if upstream is None: + continue + if upstream.kind != IN_VARIABLE: + all_from_variables = False + branches.append(_build_expr(upstream, by_id, visiting, connection.source_pin, drawn, consumed)) + # The bubble and the P or N ride on the pin, so every connection to it + # carries the same pair; the first speaks for the group. + negated = connections[0].negated + edge = connections[0].edge + if not branches: + side_pins.append((pin, "?")) + continue + feed = branches[0] if len(branches) == 1 else parallel(branches) + if all_from_variables: + # 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((pin, _pin_text(feed, connections[0], pin_blocks))) + elif power_pin is None: + power_pin = pin + power_expr = feed + # The pin's own negation bubble; it inverts the power flow at the + # box wall, after everything the rung has accumulated. The P or N + # on that pin sits there too. + power_negated = negated + power_edge = edge + else: + # A side pin fed by contacts - a reset or enable off the rail - is + # drawn as the contacts it is, wired into the pin, rather than + # flattened into the caption. The text form stays for the ST. The + # pin's own bubble or P/N goes on the box wall, as the power pin's + # does; a pin carrying both keeps the caption, which spells out + # the two where the wall has room for one. + if _is_contact_chain(feed) and not (negated and edge): + pin_feeds[pin] = feed + if negated or edge: + pin_marks[pin] = (negated, edge) + side_pins.append((pin, _pin_text(feed, connections[0], pin_blocks))) + + 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. + # Wired when a pin was named, or when anything downstream reads the + # block at all - a contact reading a block output does not always + # name the pin, and the box must still tee where it is consumed. + output_wired=via_pin is not None or (consumed is not None and node.local_id in consumed), + pin_feeds=pin_feeds, + pin_marks=pin_marks, + st_code=list(node.st_code), + power_negated=power_negated, + power_edge=power_edge, + negated_outputs=set(node.negated_outputs), + stored_outputs=node.stored_outputs, + pin_blocks=pin_blocks, + local_id=node.local_id, + ordinal=node.ordinal, + power_len=len(power_expr.items) + if isinstance(power_expr, Series) + else (0 if isinstance(power_expr, Empty) else 1), + copy=copy, + ) + return series([power_expr, element]) + + +def _pin_expr_text(expr, hoisted): + """A side pin's condition text, hoisting any box out of it. + + A box wired into a side pin is not a term of that pin's condition: it is + a call in its own right, and everything to its left on the wire is its + input, not the pin's. Flattening the whole chain into the caption states + logic the program does not have - "RESET := xB AND NOT tmrA.Q" for a + rung that resets on NOT tmrA.Q alone - so the chain up to and including + the box is hoisted into ``hoisted`` to be rendered and called as its own + sub-rung, and only the output the pin reads is named here. This is what + the power path already does; see rung_to_statements. + """ + if isinstance(expr, Series): + items = expr.items + # The last box on the wire is the one the pin reads. Anything before + # it feeds it, anything after it operates on its output. + cut = -1 + for index, item in enumerate(items): + if isinstance(item, Element) and item.kind == BLOCK: + cut = index + if cut < 0: + # No box directly on this wire, but a parallel branch on it can + # hold one, and that box is hoisted the same way. Flattening the + # branch whole named the box and never drew it. + parts = [part for part in (_pin_expr_text(item, hoisted) for item in items) if part] + return " AND ".join(parts) + hoisted.append(series(items[: cut + 1])) + parts = [expr_to_text(items[cut])] + parts += [part for part in (_pin_expr_text(item, hoisted) for item in items[cut + 1 :]) if part] + return " AND ".join([part for part in parts if part]) + if isinstance(expr, Parallel): + parts = [part for part in (_pin_expr_text(branch, hoisted) for branch in expr.branches) if part] + return "(" + " OR ".join(parts) + ")" + if isinstance(expr, Element) and expr.kind == BLOCK: + # A box feeding the pin directly still has a call to make; without + # this it is named in the caption and never called at all. + hoisted.append(expr) + return expr_to_text(expr) + return expr_to_text(expr) + + +def pin_value(text, negated=False, edge=None): + """A value as the pin receives it: bubble first, then edge detection. + + The bubble inverts what arrives; the P or N then triggers on that value + changing. Dropping the edge renders a block that runs once per change as + one that runs every cycle its input is true. + """ + if negated: + text = "NOT " + _bracket(text) if text else "NOT ?" + function = EDGE_FUNCTION.get(edge) + if function: + return function + "(" + (text or "?") + ")" + return text + + +def _pin_text(sub_expr, connection, hoisted): + """A side pin's caption, honouring the pin's own bubble and edge.""" + return pin_value(_pin_expr_text(sub_expr, hoisted), connection.negated, connection.edge) + + +def _build_expr(node, by_id, visiting, via_pin=None, drawn=None, consumed=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. ``drawn`` carries the + blocks already built for this network, so a block read by several outputs + is drawn and called once. + """ + if drawn is None: + drawn = _Built() + 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, drawn, consumed) + + 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, drawn, consumed)) + + 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)]) + + +class _Built(object): + """The blocks built so far for one POU.""" + + def __init__(self): + # {localId: the pin the block was first built for} + self.pins = {} + # {localId: the order blocks were first built in}. That follows the + # rungs in export order; a box hoisted into a side pin is built after + # the box that reads it, though it is drawn above it. + self.order = {} + # localIds of boxes with no instance name that the text names - in a + # reference to a pin, or anywhere in a side pin's caption + self.named = set() + # localIds of boxes built more than once, for readers of one pin. The + # renderer draws the copies as one box where they fuse, and names the + # rest by their pin - so these may be named in the text as well. + self.copied = set() + # {localId: how many copies of the block have been built} + self.builds = {} + + +def _number_named_boxes(logic, find, built): + """Give an ordinal to each box that its type alone would not identify. + + Only in a network holding two or more boxes of one type with no instance + name, and only where the text names one of them or may name one: a box + built more than once can be named by the renderer, which decides that only + when it draws, after the names are written. Everywhere else a box is + written as it always was. Numbered in the order the boxes are first built, + which is fixed by the export and so stable from one export to the next. + Returns True when any box was numbered, so the networks must be rebuilt. + """ + groups = {} + for node in logic: + if node.kind == BLOCK and not node.instance_name: + groups.setdefault((find(node.local_id), node.type_name), []).append(node) + + numbered = False + for group in groups.values(): + if len(group) < 2 or not any(node.local_id in built.named or node.local_id in built.copied for node in group): + continue + # A box never built draws nowhere; it goes last, in export order. + group.sort(key=lambda node: (node.local_id not in built.order, built.order.get(node.local_id, 0))) + for index, node in enumerate(group): + node.ordinal = index + 1 + numbered = True + return numbered + + +def build_networks(nodes): + """Group a flat node list into Networks, each holding its rungs. + + 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. + + Networks are the connected components, as in parse_fbd - but the rails + are left out of the grouping. CODESYS exports one left rail for the whole + LD body, not one per network, so every rung in the POU hangs off the same + element and following that wire would fuse the lot into a single network. + One network per sink is no better: a block driving three outputs is one + network in the editor, and numbering it as three throws out every number + after it. + """ + by_id = {} + for node in nodes: + by_id[node.local_id] = node + + logic = [node for node in nodes if node.kind not in RAILS and node.kind not in (COMMENT, TITLE)] + known = set(node.local_id for node in logic) + find = component_finder(logic) + + def root_of(node): + """Which network a terminal belongs to. + + A right power rail is an anchor rather than logic, so it is not in the + grouping itself - but it is the terminal of the rung that ends at it, + and it belongs to that rung's network. + """ + if node.local_id in known: + return find(node.local_id) + for connection in node.inputs: + if connection.ref_id in known: + return find(connection.ref_id) + return node.local_id + + consumed = set() + for node in nodes: + for connection in node.inputs: + consumed.add(connection.ref_id) + + def build_rungs(): + # A block read by several outputs is built once, on the first rung that + # reaches it; the rest name its output pin. Per POU, and a block belongs + # to one network, so this cannot leak across networks. + global _naming + built = _Built() + rungs_by_root = {} + _naming = built.named + try: + for node in nodes: + if node.local_id in consumed or node.kind in (LEFT_RAIL, COMMENT, TITLE): + # An unconnected left rail is an empty rung, not a terminal. + continue + expr = _build_expr(node, by_id, set(), None, built, consumed) + if isinstance(expr, Empty): + # An unconnected rail or a stray element with nothing on it. + continue + rungs_by_root.setdefault(root_of(node), []).append(expr) + finally: + _naming = None + return rungs_by_root, built + + for node in logic: + node.ordinal = None + rungs_by_root, built = build_rungs() + # Which boxes the text names is known only once the rungs are built, and + # their names are written while building - so a numbered POU builds twice. + if _number_named_boxes(logic, find, built): + rungs_by_root, built = build_rungs() + + # A component that is nothing but a jump label is the label of the network + # that follows it, not a network of its own. + label_roots = set() + for root, rungs in rungs_by_root.items(): + if rungs and all(isinstance(rung, Element) and rung.kind == LABEL for rung in rungs): + label_roots.add(root) + + def network_root(node): + if node.kind == LEFT_RAIL: + return None + return root_of(node) + + return [ + Network(comment=comment, title=title, label=label, outputs=rungs) + for comment, title, label, rungs in assemble_networks(nodes, network_root, rungs_by_root, label_roots) + ] + + +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), + networks=build_networks(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..f0479ba --- /dev/null +++ b/src/plcopen.py @@ -0,0 +1,572 @@ +# 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 - and edge="rising"/"falling", the P or N that makes the pin + see a change rather than a level. Both apply to everything arriving at + that pin, so they ride 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") + pin_edge = attr(var, "edge") + for connection in direct_connections(var): + connection.target_pin = pin + connection.negated = pin_negated + connection.edge = pin_edge + 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 stored_output_pins(block_elem): + """{pin: "set" | "reset"} for output pins that store rather than assign. + + CODESYS writes storage="set" on the pin carrying an inline assignment, + exactly as it does on a coil. A stored value is held until something + resets it, so rendering one as a plain assignment says it clears the + moment its condition drops - the opposite of what the program does. + """ + stored = {} + group = find_child(block_elem, "outputVariables") + if group is None: + return stored + for var in group: + if tag(var) != "variable": + continue + storage = attr(var, "storage") + if storage: + stored[var.get("formalParameter")] = storage + return stored + + +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() + + +# CODESYS stores a network's title as a vendorElement of this element type, +# with the text in alternativeText rather than in a content element. +NETWORK_TITLE = "networktitle" + + +def network_title(elem): + """The title text of a network-title vendorElement, else None. + + A vendorElement is CODESYS editor state and most of them hold no logic, + but this one holds the network's title - which is often the only + description a network has, and marks where a network begins even when it + has no comment. + """ + is_title = False + for child in elem.iter(): + if tag(child) == "ElementType" and (child.text or "").strip() == NETWORK_TITLE: + is_title = True + break + if not is_title: + return None + alternative = find_child(elem, "alternativeText") + if alternative is None: + return "" + xhtml = find_child(alternative, "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): + """The declared type, or UNKNOWN where the export does not carry one. + + Not BOOL: a variable whose type is missing is a variable whose type this + file does not know, and calling it BOOL states a type the program may not + have. UNKNOWN does not compile, which is the point - the rendered + declaration is for reading, and it should not read as fact. + """ + type_elem = find_child(var_elem, "type") + if type_elem is None: + return "UNKNOWN" + for child in type_elem: + name = tag(child) + if name == "derived": + return child.get("name") or "UNKNOWN" + return name + return "UNKNOWN" + + +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") + + # A character outside the Basic Multilingual Plane is a surrogate pair in + # a UTF-16 string, which is what IronPython has. Encoding that pair one + # unit at a time writes two references for one character, and a lone + # surrogate is not a legal XML character: System.Xml lets it through, + # expat rejects the document outright. So the same export parses inside + # CODESYS and fails everywhere else. + if any(0xD800 <= ord(character) <= 0xDBFF for character in text): + return _references(text) + + try: + # This error handler does exactly the job, natively. + return text.encode("ascii", "xmlcharrefreplace") + except (LookupError, ValueError): + return _references(text) + + +def _references(text): + """Numeric references, one per character, surrogate pairs recombined.""" + pieces = [] + index = 0 + while index < len(text): + code = ord(text[index]) + step = 1 + if 0xD800 <= code <= 0xDBFF and index + 1 < len(text): + low = ord(text[index + 1]) + if 0xDC00 <= low <= 0xDFFF: + code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00) + step = 2 + pieces.append(text[index] if code < 128 else "&#%d;" % code) + index += step + 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 _language_body(owner): + """(language, body_elem) for an element's own , or None.""" + body = find_child(owner, "body") + if body is None: + return None + for child in body: + if tag(child) in BODY_LANGUAGES: + return tag(child), child + return None + + +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): + found = _language_body(elem) + if found is not None: + yield elem, found[0], found[1] + + +# Sub-POU elements that carry a name and a body of their own. Matched without +# regard to case: an export writes with a capital M where +# it writes in lower case, and matching only one spelling means the +# other kind never renders at all. An SFC body's inline blocks share +# the tag but carry a localId instead of a name, so requiring the name to +# match keeps them out. +MEMBER_TAGS = ("action", "transition", "method") + + +def iter_member_bodies(source, member_name): + """Yield (pou_elem, language, body_elem) for a named sub-POU member. + + PLCopen has no top-level element for an action or transition, so CODESYS + exports the *parent* POU with the member nested inside it - parent body + included. Rendering the pou's own from such a file draws the + parent's networks under the member's filename, which is how the HMI + PLC_PRG.ACT_* dumps came to describe a different POU than the .xml + beside them. + + The member is found by name: either a pou element named for the member + itself (how some builds export methods), or an action/transition/method + element with a matching name attribute anywhere inside a pou. IEC + identifiers are case-insensitive, so the comparison is too. Yielding + nothing at all is the honest outcome when the export simply does not + carry the member's body - the caller then writes no rendering rather + than a foreign one. + """ + root = xmlbackend.parse(read_document(source)) + wanted = member_name.lower() + for pou_elem in find_pous(root): + pou_name = (pou_elem.get("name") or "").lower() + if pou_name == wanted or pou_name.endswith("." + wanted): + found = _language_body(pou_elem) + if found is not None: + yield pou_elem, found[0], found[1] + continue + for elem in pou_elem.iter(): + if tag(elem).lower() not in MEMBER_TAGS: + continue + elem_name = (elem.get("name") or "").lower() + if elem_name != wanted and not elem_name.endswith("." + wanted): + continue + found = _language_body(elem) + if found is not None: + yield pou_elem, found[0], found[1] 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 + ")" + + +# The enable pair CODESYS draws on a box. EN decides whether the box runs at +# all and ENO reports that it did; neither is an operand, and neither is the +# box's result. +EN_PIN = "EN" +ENO_PIN = "ENO" + + +def _enable(call, statements, emitted): + """The EN pin's value as text, or None when the box has no live enable. + + Re-reading the pin is safe: a call is memoised in ``emitted``, so asking + for its value again returns the same text without emitting the call a + second time, and a signal has nothing to emit. + """ + for pin, source in call.inputs: + if pin != EN_PIN: + continue + text = _fbd_value(source, statements, emitted) + # A box wired to a constant TRUE is a box with no enable worth stating. + return None if text in (None, "", "TRUE") else text + return None + + +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, OutputRef): + # The call is emitted once however many of its pins are read; only the + # value differs per reader, so it is computed here rather than + # memoised with the call. + value = _fbd_value(node.call, statements, emitted) + if node.call.is_operator: + if node.pin == ENO_PIN: + # ENO says the box ran, which is what its EN said. It is not + # the result: reading it as the expression made "xSumOk := iA + # + iB + iC;" out of a boolean that only says whether the add + # ran. + result = _enable(node.call, statements, emitted) or "TRUE" + else: + # An operator has no instance to take a pin from; it inlines + # as the one expression whichever pin reads it. + result = value + # The bubble is on the pin being read, not on whichever pin the + # box calls its active output - a box that lists ENO first still + # inverts a negated Out1. + if node.pin in node.call.negated_outputs: + result = "NOT " + _operand(result) + return result + if not node.pin: + return value + text = "%s.%s" % (node.call.instance_name, node.pin) + if node.pin in node.call.negated_outputs: + text = "NOT " + text + return text + + if isinstance(node, Signal): + return node.text + + if isinstance(node, Label): + statements.append("%s:" % node.name) + return "" + + if isinstance(node, Jump): + condition = _fbd_value(node.condition, statements, emitted) + # A return arrives here as a jump to "RETURN"; RETURN is a reserved + # word, so no label can be called that and the two cannot be confused. + statement = "RETURN;" if node.target == "RETURN" else ("JMP " + (node.target or "?") + ";") + if condition: + statements.append("IF %s THEN %s END_IF" % (condition, statement)) + else: + statements.append(statement) + return "" + + if isinstance(node, Assign): + value = _fbd_value(node.source, statements, emitted) or "FALSE" + statement = store_statement(node.label, value, node.storage, node.negated) + guard = _assign_guard(node.source, statements, emitted) + if guard: + # An operator behind an EN pin computes only while EN holds, and + # an expression has nowhere to say so - the store it feeds does. + statement = "IF %s THEN %s END_IF" % (guard, statement) + statements.append(statement) + 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) + # A store written on the ENO pin records that the box ran, which + # is what its EN said. Dropping it lost the assignment entirely. + for pin, assigned in node.outputs: + if assigned and pin == ENO_PIN: + reported = guard or "TRUE" + if pin in node.negated_outputs: + reported = "NOT " + _operand(reported) + statements.append(store_statement(assigned, reported, node.stored_outputs.get(pin))) + return remember("") + + if node.is_operator: + # Operators and functions have no instance to call, so they inline + # as an expression rather than a statement. EN is not one of the + # operands: folding it in made "iSum := xEn + iA + iB + iC;" out + # of a three-way addition that runs only while xEn. + expression = _operator_expression(node, [value for pin, value in pairs if pin != EN_PIN]) + # A store written straight onto an output pin - the MOVE-with-EN + # shape - executes while EN holds. It was dropped: the operator + # returned before this loop, so the assignment never appeared. + guard = _enable(node, statements, emitted) + for pin, assigned in node.outputs: + if not assigned: + continue + if pin == ENO_PIN: + stored = _enable(node, statements, emitted) or "TRUE" + else: + stored = expression + if pin in node.negated_outputs: + stored = "NOT " + _operand(stored) + statement = store_statement(assigned, stored, node.stored_outputs.get(pin)) + if guard and guard != "TRUE" and pin != ENO_PIN: + statement = "IF %s THEN %s END_IF" % (guard, statement) + statements.append(statement) + # The bubble on whichever pin a reader takes is applied there, in + # the OutputRef branch, so the shared expression stays un-negated. + 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(store_statement(assigned, value, node.stored_outputs.get(pin))) + 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 _assign_guard(source, statements, emitted): + """The EN a store inherits from the operator it reads, if any. + + Only for a store reading an operator's result directly. A function block + states its own EN as a call argument, and an ENO reader is the enable + rather than something the enable gates. + """ + if not isinstance(source, OutputRef) or not source.call.is_operator: + return None + if source.pin == ENO_PIN: + return None + return _enable(source.call, statements, emitted) + + +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 + + +LD = "LD" + + +def render_pou(pou): + """Render a POU as declaration plus ST statements, one block per network. + + A network holds rungs in LD and call trees in FBD; they walk differently, + so the POU's language picks the walker. + """ + lines = render_declaration(pou) + lines.append("") + + for index, network in enumerate(pou.networks): + lines.extend(network_headers(index + 1, network)) + if pou.language == LD: + for rung in network.outputs: + lines.extend(rung_to_statements(rung)) + else: + lines.extend(network_to_statements(network)) + lines.append("") + + if 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/util.py b/src/util.py index f937be4..de0929a 100644 --- a/src/util.py +++ b/src/util.py @@ -18,6 +18,11 @@ class ExportFolderLockedError(EnvironmentError): preserved in the staging folder.""" +class NothingExportedError(EnvironmentError): + """The export produced no files at all, so the existing export folder was + left untouched rather than being replaced with an empty one.""" + + def begin_export_folder(target_folder): # The export is written into a sibling staging folder and only swapped into # place once it completes, so a locked target folder or a mid-export crash @@ -29,10 +34,33 @@ def begin_export_folder(target_folder): return staging_folder +def _remove_stale_files(staging_folder, target_folder): + """Delete target files this export did not write. Returns those that stay. + + The swap path replaces the folder wholesale, so anything the export did + not produce is gone by definition. The sync path has to do the same, or a + rendering from a previous export is left beside a new native xml and + describes a POU that has since changed - with nothing to say so. Renaming + a folder is what a lock stops; deleting a file inside it usually still + works, and where it does not the file is named rather than left silent. + """ + stale = [] + for dir_path, _dir_names, file_names in os.walk(target_folder): + relative = os.path.relpath(dir_path, target_folder) + source = staging_folder if relative == "." else os.path.join(staging_folder, relative) + for file_name in file_names: + if os.path.exists(os.path.join(source, file_name)): + continue + try: + os.remove(os.path.join(dir_path, file_name)) + except (OSError, IOError): + stale.append(os.path.join(dir_path, file_name)) + return stale + + def _sync_export_files(staging_folder, target_folder): - # Overwrite sync only; files that exist in the target but not in staging are - # left in place, because deleting them would require the same folder access - # that already failed for the rename. + # Overwrite sync; anything in the target the export did not write is + # removed afterwards by _remove_stale_files. for dir_path, dir_names, file_names in os.walk(staging_folder): relative = os.path.relpath(dir_path, staging_folder) destination = target_folder if relative == "." else os.path.join(target_folder, relative) @@ -42,7 +70,28 @@ def _sync_export_files(staging_folder, target_folder): shutil.copy2(os.path.join(dir_path, file_name), os.path.join(destination, file_name)) +def _folder_has_files(folder): + for _dir_path, _dir_names, file_names in os.walk(folder): + if file_names: + return True + return False + + def finalize_export_folder(target_folder, staging_folder): + # An export that wrote no files must never replace the previous export: + # swapping in an empty staging folder silently destroys it. Seen when + # Export Lib To Files runs on a device project - its walker only exports + # objects directly under the project root, and a device project keeps + # everything under Devices, so the walk produces nothing. + if not _folder_has_files(staging_folder): + shutil.rmtree(staging_folder) + raise NothingExportedError( + "Nothing was exported, so the existing export folder was left untouched: " + + target_folder + + ". Export To Files needs a device project (objects under a Device); Export Lib To Files" + + " needs a library project (objects directly under the project root)." + ) + backup_folder = target_folder + EXPORT_BACKUP_SUFFIX try: if os.path.exists(target_folder): @@ -55,6 +104,7 @@ def finalize_export_folder(target_folder, staging_folder): # the target folder. Fall back to copying the staged files into it. try: _sync_export_files(staging_folder, target_folder) + stale = _remove_stale_files(staging_folder, target_folder) shutil.rmtree(staging_folder) except (OSError, IOError): raise ExportFolderLockedError( @@ -66,7 +116,18 @@ def finalize_export_folder(target_folder, staging_folder): + ". Original error: " + str(rename_error) ) - print("Export folder " + target_folder + " is in use; synced the staged files into it instead of swapping folders.") + print( + "Export folder " + + target_folder + + " is in use; synced the staged files into it instead of swapping folders." + ) + if stale: + print( + "WARNING: " + + str(len(stale)) + + " file(s) this export did not write could not be removed and are now stale: " + + ", ".join(stale[:6]) + ) return try: if os.path.exists(backup_folder): 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..7a5fa0f --- /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_fbd # noqa: E402 +import parse_ld # 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/36-2-fbd-two-output-pins.xml b/tools/ladder/tests/fixtures/36-2-fbd-two-output-pins.xml new file mode 100644 index 0000000..b8fcefa --- /dev/null +++ b/tools/ladder/tests/fixtures/36-2-fbd-two-output-pins.xml @@ -0,0 +1,37 @@ + + + + + + + + xStart + T#1S + + + + + + + xQ + tEt + xTrip + xLatched + xEn + iA + iB + iC + + + + + + + + + iSum + xSumOk + + + + diff --git a/tools/ladder/tests/fixtures/36-3-ld-two-coils-sp11.xml b/tools/ladder/tests/fixtures/36-3-ld-two-coils-sp11.xml new file mode 100644 index 0000000..da59384 --- /dev/null +++ b/tools/ladder/tests/fixtures/36-3-ld-two-coils-sp11.xml @@ -0,0 +1,279 @@ + + + + + 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 + + + + + + + + Lamp + + + + + + + + + + + + + + + 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/36-3-ld-two-coils.xml b/tools/ladder/tests/fixtures/36-3-ld-two-coils.xml new file mode 100644 index 0000000..d6636e2 --- /dev/null +++ b/tools/ladder/tests/fixtures/36-3-ld-two-coils.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + Network one comment + + xA + T#2S + + + + + + + + + + + xOut1 + + xOut2 + + tElapsed + Network two comment + + xB + + xOut3 + + + + + diff --git a/tools/ladder/tests/fixtures/36-5-fbd-pin-edge.xml b/tools/ladder/tests/fixtures/36-5-fbd-pin-edge.xml new file mode 100644 index 0000000..601b00d --- /dev/null +++ b/tools/ladder/tests/fixtures/36-5-fbd-pin-edge.xml @@ -0,0 +1,47 @@ + + + + + + + + xPulse + xRst + + + + + + iCv + xCntDone + iX + iY + + + + + + + iDiff + xP + xQ2 + + + + + + + xBoth + xR + + + + + + + xEither + + + + + diff --git a/tools/ladder/tests/fixtures/36-5-ld-pin-edge.xml b/tools/ladder/tests/fixtures/36-5-ld-pin-edge.xml new file mode 100644 index 0000000..8f649b2 --- /dev/null +++ b/tools/ladder/tests/fixtures/36-5-ld-pin-edge.xml @@ -0,0 +1,57 @@ + + + + + + + + + + xA + + xEdge + + xCount + + xRst + + + + + + + + 10 + + xFull + + xD + + xE + + xF + + xG + + xEn + iA + iB + + + + + + + + + + + + xAdded + + iSum + + + + + diff --git a/tools/ladder/tests/fixtures/36-6-fbd-comment-only-network.xml b/tools/ladder/tests/fixtures/36-6-fbd-comment-only-network.xml new file mode 100644 index 0000000..bc0b42b --- /dev/null +++ b/tools/ladder/tests/fixtures/36-6-fbd-comment-only-network.xml @@ -0,0 +1,19 @@ + + + + + + + + // Section header: E-STOP CHAIN (documentation-only network) + Title of network one + networktitle + // second network comment + Title of network two + networktitle + xIn + xOut + + + + diff --git a/tools/ladder/tests/fixtures/36-6-ld-comment.xml b/tools/ladder/tests/fixtures/36-6-ld-comment.xml new file mode 100644 index 0000000..56177e6 --- /dev/null +++ b/tools/ladder/tests/fixtures/36-6-ld-comment.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 + + + + + XXX - WARNING: Timer then counter + + + + + + + + + + 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/36-6-ld-empty-network.xml b/tools/ladder/tests/fixtures/36-6-ld-empty-network.xml new file mode 100644 index 0000000..7d6053b --- /dev/null +++ b/tools/ladder/tests/fixtures/36-6-ld-empty-network.xml @@ -0,0 +1,288 @@ + + + + + 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 + + + + + SECTION: safety interlocks + + + + + + + + + + networktitle + + + + + + + + + + + + + + + + + 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/38-1-ld-label-of-disabled-network.native.xml b/tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.native.xml new file mode 100644 index 0000000..a091c8f --- /dev/null +++ b/tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.native.xml @@ -0,0 +1,26 @@ + + + + + + + SKIP + True + Contact + + + + + RUN + False + Contact + + + + + + False + Contact + + + diff --git a/tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.plcopen.xml b/tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.plcopen.xml new file mode 100644 index 0000000..5bfb69d --- /dev/null +++ b/tools/ladder/tests/fixtures/38-1-ld-label-of-disabled-network.plcopen.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + + xA + + + + + + oA + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + xB + + + + + + oB + + + + + + + + + + + diff --git a/tools/ladder/tests/fixtures/action_member.plcopen.xml b/tools/ladder/tests/fixtures/action_member.plcopen.xml new file mode 100644 index 0000000..37fb0c4 --- /dev/null +++ b/tools/ladder/tests/fixtures/action_member.plcopen.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + xAction + + + + + Status.Action + + + + + + + + + + + xParent + + + + + Status.Parent + + + + + + + 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..7cb3c5f --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.art.expected.txt @@ -0,0 +1,32 @@ +(* Declaration rebuilt from the structured interface: comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *) +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..9768ca3 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/FbTesting.st.expected.txt @@ -0,0 +1,19 @@ +(* Declaration rebuilt from the structured interface: comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *) +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..569f162 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.expected.txt @@ -0,0 +1,25 @@ +(* Declaration rebuilt from the structured interface: comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *) +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)──────┤ +│ T#5S──┤PT ET│ PowerOff ┤ ├──┤RESET CV│ +│ └───────────┘ 10────────────┤PV │ +│ └───────────┘ 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..f4e91c4 --- /dev/null +++ b/tools/ladder/tests/fixtures/codesys/LDTesting.st.expected.txt @@ -0,0 +1,19 @@ +(* Declaration rebuilt from the structured interface: comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *) +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-execute-feeds-shared-box.xml b/tools/ladder/tests/fixtures/fbd-execute-feeds-shared-box.xml new file mode 100644 index 0000000..aca03c0 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-execute-feeds-shared-box.xml @@ -0,0 +1,10 @@ + + +xRun +T#1S +executenCount := nCount + 1; + +xA +tElapsed +xRan + diff --git a/tools/ladder/tests/fixtures/fbd-execute-two-readers.xml b/tools/ladder/tests/fixtures/fbd-execute-two-readers.xml new file mode 100644 index 0000000..35b7e8d --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-execute-two-readers.xml @@ -0,0 +1,9 @@ + + +xRun +executenCount := nCount + 1; +xRan +xOther + +xBoth + diff --git a/tools/ladder/tests/fixtures/fbd-fanout-two-pins.xml b/tools/ladder/tests/fixtures/fbd-fanout-two-pins.xml new file mode 100644 index 0000000..2547128 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-fanout-two-pins.xml @@ -0,0 +1,9 @@ + + +xRun +T#5S + +xA +xB +tC + diff --git a/tools/ladder/tests/fixtures/fbd-function-read-on-two-output-pins.xml b/tools/ladder/tests/fixtures/fbd-function-read-on-two-output-pins.xml new file mode 100644 index 0000000..8a684e3 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-function-read-on-two-output-pins.xml @@ -0,0 +1,8 @@ + + +xIn + +xA +xB +nC + diff --git a/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-one-node.xml b/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-one-node.xml new file mode 100644 index 0000000..4a09072 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-one-node.xml @@ -0,0 +1,8 @@ + + +xIn + +s2 + +s1 + diff --git a/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-two-nodes.xml b/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-two-nodes.xml new file mode 100644 index 0000000..d6faf10 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-function-read-on-two-pins-two-nodes.xml @@ -0,0 +1,10 @@ + + +xIn + +xIn + +s2 + +s1 + diff --git a/tools/ladder/tests/fixtures/fbd-in-out-pin-read-downstream.xml b/tools/ladder/tests/fixtures/fbd-in-out-pin-read-downstream.xml new file mode 100644 index 0000000..a95c8ae --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-in-out-pin-read-downstream.xml @@ -0,0 +1,12 @@ + + +xStart +arrData + + + + + +xDone +arrCopy + diff --git a/tools/ladder/tests/fixtures/fbd-timer-pin-read-from-a-variable-twice.xml b/tools/ladder/tests/fixtures/fbd-timer-pin-read-from-a-variable-twice.xml new file mode 100644 index 0000000..c0360e2 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-timer-pin-read-from-a-variable-twice.xml @@ -0,0 +1,32 @@ + + +xIn +T#1s + + + + + + + + + + + + + + + + +s1 + + + + + + + + +s2 +tmr.ET + diff --git a/tools/ladder/tests/fixtures/fbd-timer-pin-wired-to-two-readers.xml b/tools/ladder/tests/fixtures/fbd-timer-pin-wired-to-two-readers.xml new file mode 100644 index 0000000..46ceffd --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-timer-pin-wired-to-two-readers.xml @@ -0,0 +1,31 @@ + + +xIn +T#1s + + + + + + + + + + + + + + + + +s1 + + + + + + + + +s2 + diff --git a/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub-swapped.xml b/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub-swapped.xml new file mode 100644 index 0000000..2830e26 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub-swapped.xml @@ -0,0 +1,10 @@ + + +xRun +executenCount := nCount + 1; +executenOther := nOther + 2; +xRan +xRan2 + +xBoth + diff --git a/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub.xml b/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub.xml new file mode 100644 index 0000000..607afc8 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd-two-execute-boxes-feed-one-sub.xml @@ -0,0 +1,10 @@ + + +xRun +executenCount := nCount + 1; +executenOther := nOther + 2; +xRan +xRan2 + +xBoth + 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/fbd_shared_box.plcopen.xml b/tools/ladder/tests/fixtures/fbd_shared_box.plcopen.xml new file mode 100644 index 0000000..56ce0d0 --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd_shared_box.plcopen.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + xStart + + + + T#5S + + + + + + + + + + + + + + + + + + + + + + xDone + + + + + xManual + + + + + + + + + + + + + + + + + + + + xAny + + + + + + + + diff --git a/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml b/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml new file mode 100644 index 0000000..d1ad20f --- /dev/null +++ b/tools/ladder/tests/fixtures/fbd_storage.plcopen.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + xClear + + + + + xLatched + + + + + + + xRun + + + + + T#3S + + + + + + + + + + + + + + + xHeld + + + + + + + + + + + + xGo + + + + + xEnableIn + + + + + + + + + + + + + + + + + + xPulseOut + + + + + + + + + + + + + diff --git a/tools/ladder/tests/fixtures/ld-box-behind-a-timer-hoisted-by-a-later-rung.xml b/tools/ladder/tests/fixtures/ld-box-behind-a-timer-hoisted-by-a-later-rung.xml new file mode 100644 index 0000000..ac4b6d8 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-behind-a-timer-hoisted-by-a-later-rung.xml @@ -0,0 +1,18 @@ + + + +xGo +iA +iB +T#5S + +xA + + +xM + +xB + +xC + + diff --git a/tools/ladder/tests/fixtures/ld-box-behind-a-timer-read-by-a-later-rung.xml b/tools/ladder/tests/fixtures/ld-box-behind-a-timer-read-by-a-later-rung.xml new file mode 100644 index 0000000..9848275 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-behind-a-timer-read-by-a-later-rung.xml @@ -0,0 +1,16 @@ + + + +xGo +iA +iB +T#5S + +xA + + +xB + +xC + + diff --git a/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box-two-nodes.xml b/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box-two-nodes.xml new file mode 100644 index 0000000..69eb6ad --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box-two-nodes.xml @@ -0,0 +1,18 @@ + + + +xGo +iA +iB +iC +iD + + + +xA +xB +xM + +xC + + diff --git a/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box.xml b/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box.xml new file mode 100644 index 0000000..ba50016 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-feeding-two-coils-behind-a-repeated-box.xml @@ -0,0 +1,17 @@ + + + +xGo +iA +iB +iC +iD + + +xA +xB +xM + +xC + + diff --git a/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer-pt10.xml b/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer-pt10.xml new file mode 100644 index 0000000..ff52edb --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer-pt10.xml @@ -0,0 +1,16 @@ + + + +xGo +T#10S + +iA +iB +iC +iD + + + +xA + + diff --git a/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer.xml b/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer.xml new file mode 100644 index 0000000..5715319 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-box-on-rung-and-in-side-pin-behind-a-timer.xml @@ -0,0 +1,16 @@ + + + +xGo +T#5S + +iA +iB +iC +iD + + + +xA + + diff --git a/tools/ladder/tests/fixtures/ld-contact-chain-shared-by-part-of-a-run.xml b/tools/ladder/tests/fixtures/ld-contact-chain-shared-by-part-of-a-run.xml new file mode 100644 index 0000000..f7ed6ab --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-contact-chain-shared-by-part-of-a-run.xml @@ -0,0 +1,15 @@ + + + +c1 +c2 +c3 +A +c4 +B +c5 +C +c9 +D + + diff --git a/tools/ladder/tests/fixtures/ld-contact-feeding-a-box-read-twice-and-another-box.xml b/tools/ladder/tests/fixtures/ld-contact-feeding-a-box-read-twice-and-another-box.xml new file mode 100644 index 0000000..cfe7ccf --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-contact-feeding-a-box-read-twice-and-another-box.xml @@ -0,0 +1,13 @@ + + + +xGo +iA +iB + +xA +xB + +xC + + diff --git a/tools/ladder/tests/fixtures/ld-contact-feeds-a-marked-side-pin.xml b/tools/ladder/tests/fixtures/ld-contact-feeds-a-marked-side-pin.xml new file mode 100644 index 0000000..64fa84b --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-contact-feeds-a-marked-side-pin.xml @@ -0,0 +1,11 @@ + + + +xCount +xClear +xLoad +xDown + +xDone + + diff --git a/tools/ladder/tests/fixtures/ld-contact-feeds-a-side-pin.xml b/tools/ladder/tests/fixtures/ld-contact-feeds-a-side-pin.xml new file mode 100644 index 0000000..c49e443 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-contact-feeds-a-side-pin.xml @@ -0,0 +1,10 @@ + + + +xCount +xClear +10 + +xDone + + diff --git a/tools/ladder/tests/fixtures/ld-contact-reads-block-unnamed-pin.xml b/tools/ladder/tests/fixtures/ld-contact-reads-block-unnamed-pin.xml new file mode 100644 index 0000000..c890896 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-contact-reads-block-unnamed-pin.xml @@ -0,0 +1,13 @@ + + + +PowerOff +PowerOn +1 +2 +answer + +edgetrigger +PowerOff + + diff --git a/tools/ladder/tests/fixtures/ld-double-coil.xml b/tools/ladder/tests/fixtures/ld-double-coil.xml new file mode 100644 index 0000000..6f955fe --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-double-coil.xml @@ -0,0 +1,9 @@ + + + +xGo +xOut +xGo +xOut + + diff --git a/tools/ladder/tests/fixtures/ld-execute-read-by-a-coil-and-a-contact.xml b/tools/ladder/tests/fixtures/ld-execute-read-by-a-coil-and-a-contact.xml new file mode 100644 index 0000000..d99a6dc --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-execute-read-by-a-coil-and-a-contact.xml @@ -0,0 +1,12 @@ + + + +xGo +executenA := nA + 1; +executenB := nB + 1; +xA +xB +xK +xC + + diff --git a/tools/ladder/tests/fixtures/ld-execute-two-coils.xml b/tools/ladder/tests/fixtures/ld-execute-two-coils.xml new file mode 100644 index 0000000..a391b4d --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-execute-two-coils.xml @@ -0,0 +1,9 @@ + + + +xGo +executenCount := nCount + 1; +xA +xB + + diff --git a/tools/ladder/tests/fixtures/ld-hoisted-operator-then-read-on-its-pin.xml b/tools/ladder/tests/fixtures/ld-hoisted-operator-then-read-on-its-pin.xml new file mode 100644 index 0000000..e2b2a7c --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-hoisted-operator-then-read-on-its-pin.xml @@ -0,0 +1,13 @@ + + + +xGo +iA +iB + +xK + +xA +iSum + + diff --git a/tools/ladder/tests/fixtures/ld-hoisted-operator-two-readers.xml b/tools/ladder/tests/fixtures/ld-hoisted-operator-two-readers.xml new file mode 100644 index 0000000..3601167 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-hoisted-operator-two-readers.xml @@ -0,0 +1,13 @@ + + + +xGo +iA +iB +iC + + +xA +xB + + diff --git a/tools/ladder/tests/fixtures/ld-hoisted-or-under-two-coils.xml b/tools/ladder/tests/fixtures/ld-hoisted-or-under-two-coils.xml new file mode 100644 index 0000000..60c42dc --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-hoisted-or-under-two-coils.xml @@ -0,0 +1,12 @@ + + + +xGo +xP +xQ + + +xA +xB + + diff --git a/tools/ladder/tests/fixtures/ld-label-comment-label.native.xml b/tools/ladder/tests/fixtures/ld-label-comment-label.native.xml new file mode 100644 index 0000000..9b5a711 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-label-comment-label.native.xml @@ -0,0 +1,26 @@ + + + + + + + LA + False + + + + second one + + LB + False + + + + + + + False + Contact + + + diff --git a/tools/ladder/tests/fixtures/ld-label-comment-label.plcopen.xml b/tools/ladder/tests/fixtures/ld-label-comment-label.plcopen.xml new file mode 100644 index 0000000..efff749 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-label-comment-label.plcopen.xml @@ -0,0 +1,10 @@ + + + +second one + + +xA +oA + + diff --git a/tools/ladder/tests/fixtures/ld-operator-across-rungs.xml b/tools/ladder/tests/fixtures/ld-operator-across-rungs.xml new file mode 100644 index 0000000..ad0eb5a --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-across-rungs.xml @@ -0,0 +1,11 @@ + + + +xGo + +xA + + +xB + + diff --git a/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption-swapped.xml b/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption-swapped.xml new file mode 100644 index 0000000..60afa05 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption-swapped.xml @@ -0,0 +1,18 @@ + + + +xGo +xQ +xS +10 + +xA + +xB +xC +xM +xE + +xFull + + diff --git a/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption.xml b/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption.xml new file mode 100644 index 0000000..0b08b33 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-named-in-a-nested-pin-caption.xml @@ -0,0 +1,18 @@ + + + +xGo +xQ +xS +10 + +xA + +xB +xC +xM +xE + +xFull + + diff --git a/tools/ladder/tests/fixtures/ld-operator-only-in-a-nested-caption.xml b/tools/ladder/tests/fixtures/ld-operator-only-in-a-nested-caption.xml new file mode 100644 index 0000000..a580098 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-only-in-a-nested-caption.xml @@ -0,0 +1,17 @@ + + + +xGo +iA +iB +iC + + +iSum +xC +xM +xE + +iProd + + diff --git a/tools/ladder/tests/fixtures/ld-operator-read-by-two-rung-shapes.xml b/tools/ladder/tests/fixtures/ld-operator-read-by-two-rung-shapes.xml new file mode 100644 index 0000000..19aead6 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-read-by-two-rung-shapes.xml @@ -0,0 +1,12 @@ + + + +xGo +iA +iB + +xA +xK +xB + + diff --git a/tools/ladder/tests/fixtures/ld-operator-read-on-two-pins.xml b/tools/ladder/tests/fixtures/ld-operator-read-on-two-pins.xml new file mode 100644 index 0000000..c4553c9 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-read-on-two-pins.xml @@ -0,0 +1,11 @@ + + + +xGo +iA +iB + +xDone +iSum + + diff --git a/tools/ladder/tests/fixtures/ld-operator-with-instance-side-pin.xml b/tools/ladder/tests/fixtures/ld-operator-with-instance-side-pin.xml new file mode 100644 index 0000000..cec0a54 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-operator-with-instance-side-pin.xml @@ -0,0 +1,12 @@ + + + +xGo +xT +T#1S + + +xA +xB + + diff --git a/tools/ladder/tests/fixtures/ld-or-into-block-pin.xml b/tools/ladder/tests/fixtures/ld-or-into-block-pin.xml new file mode 100644 index 0000000..dabbc1d --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-or-into-block-pin.xml @@ -0,0 +1,9 @@ + + + +xStart +xRun + +xOut + + diff --git a/tools/ladder/tests/fixtures/ld-return-and-coil-share-a-box.xml b/tools/ladder/tests/fixtures/ld-return-and-coil-share-a-box.xml new file mode 100644 index 0000000..998315b --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-return-and-coil-share-a-box.xml @@ -0,0 +1,13 @@ + + + +PowerOff +PowerOn +1 +2 + + +edgetrigger +PowerOff + + diff --git a/tools/ladder/tests/fixtures/ld-shared-prefix-branches.xml b/tools/ladder/tests/fixtures/ld-shared-prefix-branches.xml new file mode 100644 index 0000000..25a5a20 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-shared-prefix-branches.xml @@ -0,0 +1,8 @@ + + + +xGo +xA +xB + + diff --git a/tools/ladder/tests/fixtures/ld-two-boxes-two-coils-each.xml b/tools/ladder/tests/fixtures/ld-two-boxes-two-coils-each.xml new file mode 100644 index 0000000..d443d74 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-boxes-two-coils-each.xml @@ -0,0 +1,16 @@ + + + +xGo +iA +iB + +xA +xB + +xC +xD +xK +xE + + diff --git a/tools/ladder/tests/fixtures/ld-two-connections-from-one-contact.xml b/tools/ladder/tests/fixtures/ld-two-connections-from-one-contact.xml new file mode 100644 index 0000000..8528acd --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-connections-from-one-contact.xml @@ -0,0 +1,7 @@ + + + +c +xA + + diff --git a/tools/ladder/tests/fixtures/ld-two-connections-from-one-pin-into-a-coil.xml b/tools/ladder/tests/fixtures/ld-two-connections-from-one-pin-into-a-coil.xml new file mode 100644 index 0000000..584cf36 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-connections-from-one-pin-into-a-coil.xml @@ -0,0 +1,10 @@ + + + +c +iA +iB + +xA + + diff --git a/tools/ladder/tests/fixtures/ld-two-connections-into-one-box-pin.xml b/tools/ladder/tests/fixtures/ld-two-connections-into-one-box-pin.xml new file mode 100644 index 0000000..f902193 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-connections-into-one-box-pin.xml @@ -0,0 +1,10 @@ + + + +c +iA +iB + +xA + + diff --git a/tools/ladder/tests/fixtures/ld-two-execute-boxes-alike.xml b/tools/ladder/tests/fixtures/ld-two-execute-boxes-alike.xml new file mode 100644 index 0000000..d24d411 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-execute-boxes-alike.xml @@ -0,0 +1,10 @@ + + + +xGo +executenCount := nCount + 1; +xA +executenCount := nCount + 1; +xB + + diff --git a/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.native.xml b/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.native.xml new file mode 100644 index 0000000..635137d --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.native.xml @@ -0,0 +1,33 @@ + + + + + + + + False + Contact + + + + + LONELY1 + False + + + + + + LONELY2 + False + + + + + + + False + Contact + + + diff --git a/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.plcopen.xml b/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.plcopen.xml new file mode 100644 index 0000000..89b58e3 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-labels-on-empty-networks.plcopen.xml @@ -0,0 +1,12 @@ + + + +xGo + + + + +xA +oA + + diff --git a/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins-swapped.xml b/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins-swapped.xml new file mode 100644 index 0000000..c6239cb --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins-swapped.xml @@ -0,0 +1,14 @@ + + + +xGo +iA +iB + +xA +iSum + +xB +iDiff + + diff --git a/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins.xml b/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins.xml new file mode 100644 index 0000000..ab7dbb6 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld-two-operators-each-read-on-two-pins.xml @@ -0,0 +1,14 @@ + + + +xGo +iA +iB + +xA +iSum + +xB +iDiff + + diff --git a/tools/ladder/tests/fixtures/ld_execute.plcopen.xml b/tools/ladder/tests/fixtures/ld_execute.plcopen.xml new file mode 100644 index 0000000..f95da05 --- /dev/null +++ b/tools/ladder/tests/fixtures/ld_execute.plcopen.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xRun + + + + + + + + + + + + + + + + + + + + execute + + + iCount := iCount + 1; +IF iCount > 10 THEN +iCount := 0; +END_IF + + + + + + + + + + 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/ld_side_pin_latch.plcopen.xml b/tools/ladder/tests/fixtures/ld_side_pin_latch.plcopen.xml new file mode 100644 index 0000000..a85813e --- /dev/null +++ b/tools/ladder/tests/fixtures/ld_side_pin_latch.plcopen.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xSet + + + + + xClear + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xCount + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xDone + + + + + + + + + + + + + + + diff --git a/tools/ladder/tests/fixtures/method_member.plcopen.xml b/tools/ladder/tests/fixtures/method_member.plcopen.xml new file mode 100644 index 0000000..c5ea8ea --- /dev/null +++ b/tools/ladder/tests/fixtures/method_member.plcopen.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + xMethod + + + + + Status.Method + + + + + + + + + + + + xParent + + + + + Status.Parent + + + + + + + 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..1b01467 --- /dev/null +++ b/tools/ladder/tests/fixtures/motor_control.expected.txt @@ -0,0 +1,26 @@ +(* Declaration rebuilt from the structured interface: comments, pragmas and attributes are missing; an omitted type reads UNKNOWN. *) +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/fixtures/native_networks.xml b/tools/ladder/tests/fixtures/native_networks.xml new file mode 100644 index 0000000..d7b7517 --- /dev/null +++ b/tools/ladder/tests/fixtures/native_networks.xml @@ -0,0 +1,58 @@ + + + + + + // counts the pulses + Pulse counter + + False + + + CTU + + + + + // switched off while we test the interlock + + + True + + + TON + + + + + + + RETRY + False + + + + SECTION: shutdown + + + False + + + + diff --git a/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-et-and-q.xml b/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-et-and-q.xml new file mode 100644 index 0000000..1f4a483 --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-et-and-q.xml @@ -0,0 +1,11 @@ + + +xStart +T#5S + +xManual + +xAny +tElapsed + + diff --git a/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-q-and-et.xml b/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-q-and-et.xml new file mode 100644 index 0000000..cc902c5 --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-1-fbd-shared-box-q-and-et.xml @@ -0,0 +1,11 @@ + + +xStart +T#5S + +tElapsed +xManual + +xAny + + diff --git a/tools/ladder/tests/fixtures/r2-2-fbd-execute-eno-wired.xml b/tools/ladder/tests/fixtures/r2-2-fbd-execute-eno-wired.xml new file mode 100644 index 0000000..dc48277 --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-2-fbd-execute-eno-wired.xml @@ -0,0 +1,8 @@ + + +xRun +executea := 1; +b := 2; +xRan + + diff --git a/tools/ladder/tests/fixtures/r2-3-fbd-counter-q-and-cv-in-one-expression.xml b/tools/ladder/tests/fixtures/r2-3-fbd-counter-q-and-cv-in-one-expression.xml new file mode 100644 index 0000000..1e7e783 --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-3-fbd-counter-q-and-cv-in-one-expression.xml @@ -0,0 +1,12 @@ + + +xPulse +xRst +10 + +5 + + +xAlarm + + diff --git a/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.native.xml b/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.native.xml new file mode 100644 index 0000000..6a429c6 --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.native.xml @@ -0,0 +1,40 @@ + + + + + + + + False + Contact + + + + + LATER + False + Contact + + + + + LONELY + False + + + + + + + False + Contact + + + + + + False + Contact + + + diff --git a/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.plcopen.xml b/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.plcopen.xml new file mode 100644 index 0000000..1fc31ea --- /dev/null +++ b/tools/ladder/tests/fixtures/r2-4-ld-label-on-wired-network.plcopen.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + xGo + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + + xA + + + + + + oA + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + xB + + + + + + + + + + + + + + + networktitle + + + + + + + + + + + + xC + + + + + + oC + + + + + + + + + + + diff --git a/tools/ladder/tests/fixtures/two_pous.plcopen.xml b/tools/ladder/tests/fixtures/two_pous.plcopen.xml new file mode 100644 index 0000000..1d4a07f --- /dev/null +++ b/tools/ladder/tests/fixtures/two_pous.plcopen.xml @@ -0,0 +1,32 @@ + + + + + + + + // Section header: E-STOP CHAIN (documentation-only network) + Title of network one + networktitle + // second network comment + Title of network two + networktitle + xIn + xOut + + + + + + // Section header: E-STOP CHAIN (documentation-only network) + Title of network one + networktitle + // second network comment + Title of network two + networktitle + xInTwo + xOutTwo + + + + diff --git a/tools/ladder/tests/test_export.py b/tools/ladder/tests/test_export.py new file mode 100644 index 0000000..07a9301 --- /dev/null +++ b/tools/ladder/tests/test_export.py @@ -0,0 +1,931 @@ +# 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 +sys.path.insert(0, os.path.join(REPO, "tools", "ladder")) + +from render import write # noqa: E402 + +import graphical_export # noqa: E402 +import import_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) + return + failures.append(name) + # The detail quotes rendered lines, which hold box-drawing characters a + # Windows console cannot encode. print() raises UnicodeEncodeError on + # exactly those, which aborts the run at the first golden mismatch - so + # the failures after it are never reported and the suite looks shorter + # than it is rather than looking broken. + sys.stdout.flush() + write(["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 + + # The documented SP11 overload, in full: + # export_xml(path, recursive, export_folder_structure, declarations_as_plaintext). + # Taking fewer arguments than that made the first attempt fail to bind, so + # the tests exercised a fallback and a swap of the two booleans would have + # gone unnoticed - which is the one mistake this signature can hide. + def export_xml(self, path, recursive, export_folder_structure=None, declarations_as_plaintext=None): + self.export_calls.append((path, recursive, export_folder_structure, 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) + check_equal("the documented four-argument overload binds", len(pou.export_calls[0]), 4) + check_equal("no folder structure is asked for", pou.export_calls[0][2], False) + # Without this the declaration loses comments, pragmas and attributes. + check_equal("plaintext declarations are requested", pou.export_calls[0][3], True) + + content = read(base + ".txt") + check("derived file leads with the declaration", "PROGRAM LD_TEST" in content.split("\n")[1]) + # The diagram file holds the diagram. The two notations were written into + # one file at first and that was worse, not better: the same network twice, + # one rendering after the other, is harder to read than either alone. + check("no ST rendering in the diagram file", "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()) + # A bare "skipped 1" reads like something went missing; the summary must + # say which POU and why. + check("the summary names the skipped pou", "SFC/CFC: SFC_TEST" 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) + + +# --- sub-POU members render their own body, never the parent's -------------- + +# An action's PLCopen export wraps it in its parent POU, parent body included. +# Without the member name the rendering drew the parent's networks under the +# action's filename - a dump describing a different POU than the .xml beside +# it, which a reviewer has no way to notice. +ACTION_FIXTURE = os.path.join(HERE, "fixtures", "action_member.plcopen.xml") +METHOD_FIXTURE = os.path.join(HERE, "fixtures", "method_member.plcopen.xml") + +workspace = tempfile.mkdtemp() +try: + graphical_export.reset_stats() + + action_base = os.path.join(workspace, "PLC_TEST.ACT_TEST") + action = FakePou("ACT_TEST", ACTION_FIXTURE) + check( + "an action renders", + graphical_export.write_rendered_text(action, action_base, member_name="ACT_TEST") is True, + ) + action_content = read(action_base + ".txt") + check("the action's own body is drawn", "Status.Action" in action_content) + check("the parent's body is not drawn", "Status.Parent" not in action_content) + check("the rendering is titled for the member", "PLC_TEST.ACT_TEST" in action_content) + check( + "the rendering says whose declaration it shows", + "the declaration below is the parent POU's" in action_content, + ) + + # A graphical method: CODESYS spells the tag with a capital M + # where it spells actions . Matching one case only meant methods + # never found their own body and silently rendered nothing. + method_base = os.path.join(workspace, "FB_TEST.Compute") + method = FakePou("Compute", METHOD_FIXTURE) + check( + "a graphical method renders", + graphical_export.write_rendered_text(method, method_base, member_name="Compute") is True, + ) + method_content = read(method_base + ".txt") + check("the method's own body is drawn", "Status.Method" in method_content) + check("the method does not draw the parent", "Status.Parent" not in method_content) + + # A method carries its own declaration, so the rendering must not claim + # the declaration below is the parent's - the note is only for members + # that open with the parent's declaration, like an action. + method_decl_base = os.path.join(workspace, "FB_TEST.ComputeDecl") + method_decl = FakePou("Compute", METHOD_FIXTURE, declaration="METHOD Compute : INT\nVAR_INPUT\nEND_VAR") + check( + "a method with its own declaration renders", + graphical_export.write_rendered_text(method_decl, method_decl_base, member_name="Compute") is True, + ) + method_decl_content = read(method_decl_base + ".txt") + check("the method shows its own declaration", "METHOD Compute : INT" in method_decl_content) + check( + "the method rendering does not claim the parent's declaration", + "the declaration below is the parent POU's" not in method_decl_content, + ) + + # The parent's own rendering must still be the parent body, members + # excluded - iter_bodies only ever took the pou's direct . + parent_base = os.path.join(workspace, "PLC_TEST") + parent = FakePou("PLC_TEST", ACTION_FIXTURE) + check("the parent still renders", graphical_export.write_rendered_text(parent, parent_base) is True) + parent_content = read(parent_base + ".txt") + check("the parent draws its own body", "Status.Parent" in parent_content) + check("the parent does not absorb the action", "Status.Action" not in parent_content) + check( + "the parent's dump carries no member note", + "the declaration below is the parent POU's" not in parent_content, + ) + + # A member whose body the export does not carry must produce NO file: an + # absent rendering sends the reviewer to the native xml, a foreign one + # does not. + missing_base = os.path.join(workspace, "PLC_TEST.ACT_MISSING") + missing = FakePou("ACT_MISSING", os.path.join(FIXTURES, "LDTesting.xml")) + check( + "a member the export lacks writes nothing", + graphical_export.write_rendered_text(missing, missing_base, member_name="ACT_MISSING") is False, + ) + check("no foreign dump is written", not os.path.exists(missing_base + ".txt")) + check_equal("the missing member is counted", graphical_export.STATS["members_missing"], 1) + check("the summary reports the missing member", "nothing was written for those" in graphical_export.summary()) +finally: + shutil.rmtree(workspace) + + +# --- the editor's own network numbering ------------------------------------- + +# CODESYS leaves out of the PLCopen export every network that carries no +# elements - an out-commented one goes entirely, comment included, and so does +# an empty one. Numbering what survives 1..n drifts from what the editor +# shows, so a reviewer opening "Network 5" in CODESYS reads different logic +# under "(* Network 5 *)". The native export beside the rendering carries the +# full list, and it is the authority for the structure. +import native_networks # noqa: E402 + +EXPORT = os.path.join(REPO, "GraphicalTesting") + + +def native_of(pou_path): + return native_networks.read_networks(pou_path) + + +# Pinned to a fixture, not to the real project: a network switched back on in +# CODESYS should not fail a test about reading flags. +sample = native_of(os.path.join(HERE, "fixtures", "native_networks.xml")) +check_equal("every network in the list is read", len(sample), 4) +check("a network with a body is neither out-commented nor empty", sample[0].has_logic) +check_equal("its title is read", sample[0].title, "Pulse counter") +check_equal("its comment is read", sample[0].comment, "// counts the pulses") +check("an out-commented network is marked", sample[1].out_commented) +check("and it keeps the comment PLCopen throws away", sample[1].comment.startswith("// switched off")) +check("an out-commented network has no body to match", not sample[1].has_logic) +check("an empty network is marked empty", sample[2].empty) +check_equal("the label is on the network that owns it", sample[2].label, "RETRY") +check_equal("a comment-only network keeps its comment", sample[3].comment, "SECTION: shutdown") +check_equal("only the network with a body takes part in the match", len([n for n in sample if n.has_logic]), 1) + +# The committed rendering is the worked example, so it has to agree with the +# native list beside it - one numbered network per editor network, in order, +# each headed by its own title or comment. Without this a renderer change +# rewrites those files and nothing notices. +for device in ("SafetyPLC", "StandardPLC"): + folder = os.path.join(EXPORT, device, "application") + for name in sorted(os.listdir(folder)): + if not name.endswith(".xml"): + continue + native = native_of(os.path.join(folder, name)) + rendered = os.path.join(folder, name[: -len(".xml")] + ".txt") + if native is None or not os.path.exists(rendered): + continue + headers = [line for line in read(rendered).split("\n") if line.startswith("(* Network ")] + check_equal("committed " + name + ": one header per editor network", len(headers), len(native)) + for index, entry in enumerate(native): + heading = entry.title or entry.comment + expected = "(* Network " + str(index + 1) + if heading: + expected += ": " + heading.replace("*)", "* )").lstrip("/").strip() + check_equal( + "committed " + name + " network " + str(index + 1), + headers[index], + expected + " *)", + ) + + +# A file holding more than one renderable POU cannot use the list at all: it +# belongs to one POU and says nothing about which. That is a failure to line +# them up, not a reason to say nothing - the file has to carry the warning +# either way, or the README's promise that it always says so is false. +graphical_export.reset_stats() +TWO_POUS = os.path.join(HERE, "fixtures", "two_pous.plcopen.xml") +NATIVE = os.path.join(HERE, "fixtures", "native_networks.xml") +two_pou_lines = graphical_export.render_plcopen(TWO_POUS, None, None, NATIVE) +check("two POUs in one file: the rendering warns", graphical_export.ALIGNMENT_WARNING in two_pou_lines) +check_equal("two POUs in one file: the failure is counted", graphical_export.STATS["alignment_failures"], 1) +check("two POUs in one file: both are still drawn", "xOutTwo" in "".join(two_pou_lines)) +graphical_export.reset_stats() + + +# The alignment refuses rather than guesses. One body more on the parsed side +# than the native list accounts for means an assumption broke, and a silently +# misnumbered file is worse than one that says it could not tell. +class FakeNetwork(object): + def __init__(self, outputs): + self.outputs = outputs + + +spare = [FakeNetwork(["logic"]) for _ in range(2)] +check("a body the native list cannot account for is refused", native_networks.align(sample, spare) is None) +check("and no native list at all is refused", native_networks.align(None, spare) is None) + +# The right number of bodies lines up, and the networks without one keep their +# number and say why they have no diagram. +aligned = native_networks.align(sample, [FakeNetwork(["logic"])]) +check_equal("one entry per network the editor shows", len(aligned), 4) +check_equal("the body lands on the network that has one", aligned[0].outputs, ["logic"]) +check_equal("an out-commented network says it does not execute", aligned[1].note, native_networks.NOTE_OUT_COMMENTED) +check_equal("an empty one says it is empty", aligned[2].note, native_networks.NOTE_EMPTY) +check_equal("and carries its label", aligned[2].label, "RETRY") + + +# --- a label on a network that has logic ------------------------------------- + +# CODESYS keeps a jump label on its network; PLCopen writes it as a loose +# element just before the network's body. The header wrote the label from the +# native list, and the parsed element was still in the body, so the label +# came out twice: under the header, and again as a rung. The committed +# GraphicalTesting files never showed it, because both of their labels sit +# on empty networks, whose label element parses into a network of its own. +LABELLED = os.path.join(HERE, "fixtures", "r2-4-ld-label-on-wired-network.plcopen.xml") +LABELLED_NATIVE = os.path.join(HERE, "fixtures", "r2-4-ld-label-on-wired-network.native.xml") +graphical_export.reset_stats() +labelled = graphical_export.render_plcopen(LABELLED, None, None, LABELLED_NATIVE) +check("labelled: the networks line up with the native list", graphical_export.ALIGNMENT_WARNING not in labelled) +check_equal( + "labelled: one header per editor network", len([line for line in labelled if line.startswith("(* Network ")]), 5 +) +second = labelled.index("(* Network 2 *)") +check_equal("labelled: the label is under its header", labelled[second + 1], "LATER:") +check("labelled: the rung follows it", "xA" in labelled[second + 2] and "oA" in labelled[second + 2]) +check_equal("labelled: the label appears once", len([line for line in labelled if "LATER:" in line]), 1) +third = labelled.index("(* Network 3 *)") +check_equal( + "labelled: a label on an empty network stays with it", + labelled[third + 1 : third + 3], + ["LONELY:", "(* " + native_networks.NOTE_EMPTY + " *)"], +) +check_equal("labelled: that label appears once too", len([line for line in labelled if "LONELY:" in line]), 1) +check("labelled: the jump still names its target", any(">>LATER" in line for line in labelled)) + +# The same file without the native list, as the dev CLI renders it: the label +# is the network's own either way, so it is written once, under the header. +unlabelled = graphical_export.render_plcopen(LABELLED, None, None, None) +check_equal( + "labelled: without the native list the label still appears once", + len([line for line in unlabelled if "LATER:" in line]), + 1, +) +check_equal("labelled: and under its header", unlabelled[unlabelled.index("(* Network 2 *)") + 1], "LATER:") +graphical_export.reset_stats() + +# Two empty labelled networks in a row, with no comment element between them. +# Both label elements were carried to the next network with logic; the first +# became its label and the second stayed in its body, drawn as a rung. A label +# arriving while one is already carried means the first headed a network with +# no logic, and that network is closed there. +TWO_LABELS = os.path.join(HERE, "fixtures", "ld-two-labels-on-empty-networks.plcopen.xml") +TWO_LABELS_NATIVE = os.path.join(HERE, "fixtures", "ld-two-labels-on-empty-networks.native.xml") +graphical_export.reset_stats() +two_labels = graphical_export.render_plcopen(TWO_LABELS, None, None, TWO_LABELS_NATIVE) +check("two labels: the networks line up with the native list", graphical_export.ALIGNMENT_WARNING not in two_labels) +check_equal("two labels: one header per editor network", len([l for l in two_labels if l.startswith("(* Network ")]), 4) +check_equal( + "two labels: each label is written once", [two_labels.count("LONELY1:"), two_labels.count("LONELY2:")], [1, 1] +) +check( + "two labels: no label is drawn as a rung", + not any("LONELY" in l and ":" in l and l not in ("LONELY1:", "LONELY2:") for l in two_labels), +) +fourth = two_labels.index("(* Network 4 *)") +check("two labels: the network with logic carries no label", "xA" in two_labels[fourth + 1]) +two_labels_bare = graphical_export.render_plcopen(TWO_LABELS, None, None, None) +check_equal( + "two labels: without the native list each label is written once", + [two_labels_bare.count("LONELY1:"), two_labels_bare.count("LONELY2:")], + [1, 1], +) +check( + "two labels: without the native list no label is drawn as a rung", + not any("LONELY" in l and ":" in l and l not in ("LONELY1:", "LONELY2:") for l in two_labels_bare), +) +graphical_export.reset_stats() + +# CODESYS writes a network's comment before its label. A comment arriving while +# a label is carried therefore heads the next network, and the carried label's +# network ends first. Closing it only at the next label gave it the comment. +COMMENT_BETWEEN = os.path.join(HERE, "fixtures", "ld-label-comment-label.plcopen.xml") +COMMENT_BETWEEN_NATIVE = os.path.join(HERE, "fixtures", "ld-label-comment-label.native.xml") +graphical_export.reset_stats() +for native, name in ((COMMENT_BETWEEN_NATIVE, "with"), (None, "without")): + between = graphical_export.render_plcopen(COMMENT_BETWEEN, None, None, native) + headed = [between[i + 1] for i, l in enumerate(between) if l.startswith("(* Network ") and "second one" in l] + check_equal( + "comment between labels, " + name + " the native list: the comment heads the LB network", headed, ["LB:"] + ) + check( + "comment between labels, " + name + " the native list: LA carries no comment", + not any( + l.startswith("(* Network ") and "second one" in l and between[i + 1] == "LA:" for i, l in enumerate(between) + ), + ) +graphical_export.reset_stats() + + +# --- the label of an out-commented network ----------------------------------- + +# Item 38-4.1. The native list has network 1 out-commented with the label +# SKIP, and network 2 with the label RUN and a body; the PLCopen export +# carries network 2 alone, its label element first. The old alignment asked +# only whether the native network had a label and whether the next parsed +# element was one, so network 1 consumed RUN: the file showed RUN under the +# "does not execute" line and no label on network 2, and a jump to RUN +# appeared to target dead code. The rebuilt alignment takes every label from +# the native list, on the network that owns it; this pins that. +DISABLED = os.path.join(HERE, "fixtures", "38-1-ld-label-of-disabled-network.plcopen.xml") +DISABLED_NATIVE = os.path.join(HERE, "fixtures", "38-1-ld-label-of-disabled-network.native.xml") +graphical_export.reset_stats() +disabled = graphical_export.render_plcopen(DISABLED, None, None, DISABLED_NATIVE) +check("disabled label: the networks line up with the native list", graphical_export.ALIGNMENT_WARNING not in disabled) +check_equal( + "disabled label: one header per editor network", + len([line for line in disabled if line.startswith("(* Network ")]), + 3, +) +first = disabled.index("(* Network 1 *)") +second = disabled.index("(* Network 2 *)") +third = disabled.index("(* Network 3 *)") +check_equal( + "disabled label: the out-commented network keeps its own label", + disabled[first + 1 : first + 3], + ["SKIP:", "(* " + native_networks.NOTE_OUT_COMMENTED + " *)"], +) +check("disabled label: it does not take the next network's label", "RUN:" not in disabled[first:second]) +check_equal("disabled label: the next network keeps its label", disabled[second + 1], "RUN:") +check("disabled label: and its body", "xA" in disabled[second + 2] and "oA" in disabled[second + 2]) +check("disabled label: the network after carries no label", "xB" in disabled[third + 1]) +check_equal("disabled label: each label appears once", [disabled.count("SKIP:"), disabled.count("RUN:")], [1, 1]) +graphical_export.reset_stats() + + +# A native list beside a file with nothing renderable in it - a member export +# that carried no body of its own, or an SFC-only file - must not be counted +# as a failure to line up, nor warn about numbering for a POU never drawn. +graphical_export.reset_stats() +SFC_ONLY = os.path.join(HERE, "fixtures", "codesys", "SFCTesting.xml") +NATIVE_LIST = os.path.join(HERE, "fixtures", "native_networks.xml") +no_pou_lines = graphical_export.render_plcopen(SFC_ONLY, None, None, NATIVE_LIST) +check_equal("no renderable POU: nothing is drawn", no_pou_lines, []) +check_equal("no renderable POU: no alignment failure is counted", graphical_export.STATS["alignment_failures"], 0) +check("no renderable POU: no numbering warning is emitted", graphical_export.ALIGNMENT_WARNING not in no_pou_lines) +graphical_export.reset_stats() + + +# --- read-only service exports: library list and visualisation manager ------ + +# Library behaviour is not exportable, but which exact versions the project +# resolves is - and a bench check of a library is only meaningful against the +# version it characterises. The visualisation manager carries the global +# hotkey mapping nothing else exports; importing it raises interactive +# overwrite dialogs, so it exports read-only under a suffix the importer +# ignores by construction. + + +class FakeReference(object): + def __init__(self, display_name=None, name=None, version=None, company=None, default_resolution=None): + if display_name is not None: + self.display_name = display_name + if name is not None: + self.name = name + if version is not None: + self.version = version + if company is not None: + self.company = company + if default_resolution is not None: + self.default_resolution = default_resolution + + def __str__(self): + return "raw reference" + + +class FakeLibManager(object): + def __init__(self, name, references=None, names=None): + self._name = name + if references is not None: + self.references = references + self._names = names + + def get_name(self): + return self._name + + def get_libraries(self): + if self._names is None: + raise RuntimeError("no such API on this build") + return self._names + + +class FakeVisuManager(object): + def __init__(self, name): + self._name = name + self.calls = [] + + def get_name(self): + return self._name + + def export_native(self, path, recursive=False): + self.calls.append((path, recursive)) + handle = io.open(path, "w", encoding="utf-8") + handle.write("\n") + handle.close() + + +workspace = tempfile.mkdtemp() +try: + manager = FakeLibManager( + "Library Manager", + references=[ + # A display name that already carries version and company must not + # have them appended again. + FakeReference( + display_name="ifmIOcommon, 1.5.0.0 (ifm electronic gmbh)", + version="1.5.0.0", + company="ifm electronic gmbh", + ), + FakeReference(name="Standard", version="3.5.11.0", company="3S"), + # A placeholder pins no version by itself; its resolution must show. + FakeReference(display_name="#Util", default_resolution="Util, 3.5.11.0 (System)"), + FakeReference(), # nothing probeable - falls back to str() + ], + ) + import_export.export_library_manager(manager, None, workspace, None) + lib_list_path = os.path.join(workspace, "Library Manager.libraries.txt") + check("the library list is written", os.path.exists(lib_list_path)) + lib_list = read(lib_list_path) + check("the list says it is read-only", "read-only" in lib_list) + check("a display name is taken verbatim", "ifmIOcommon, 1.5.0.0 (ifm electronic gmbh)\n" in lib_list) + check("embedded details are not duplicated", lib_list.count("1.5.0.0") == 1) + check("probed details are assembled", "Standard, 3.5.11.0 (3S)" in lib_list) + check("a placeholder shows its resolution", "#Util -> Util, 3.5.11.0 (System)" in lib_list) + check("an opaque reference still lands as a line", "raw reference" in lib_list) + + # An older build without .references still exports via get_libraries. + named_only = FakeLibManager("Library Manager", names=["OldLib, 1.0.0.0 (Vendor)"]) + named_dir = tempfile.mkdtemp() + try: + import_export.export_library_manager(named_only, None, named_dir, None) + named_list = read(os.path.join(named_dir, "Library Manager.libraries.txt")) + check("get_libraries is the fallback", "OldLib, 1.0.0.0 (Vendor)" in named_list) + finally: + shutil.rmtree(named_dir) + + # A manager exposing neither API must warn, not raise, and write nothing. + broken_dir = tempfile.mkdtemp() + try: + broken = FakeLibManager("Library Manager") + try: + import_export.export_library_manager(broken, None, broken_dir, None) + check("a hostile lib manager is reported, not raised", True) + except Exception as error: + check("a hostile lib manager is reported, not raised", False, repr(error)) + check("no library list is written for it", os.listdir(broken_dir) == []) + finally: + shutil.rmtree(broken_dir) + + visu = FakeVisuManager("Visualization Manager") + import_export.export_visualisation_manager(visu, None, workspace, None) + service_path = os.path.join(workspace, "Visualization Manager.service.txt") + check("the visualisation manager is exported", os.path.exists(service_path)) + # The key configuration and target/web visualisations live under the + # manager, so a flat export would miss the hotkey mapping entirely. + check_equal("the manager export is recursive", visu.calls[0][1], True) + + # The safety property behind the separate SERVICE table: import must never + # remove these objects, because nothing would recreate them. + class FakeTrackedObject(object): + def __init__(self, type_guid): + self.type = type_guid + self.removed = False + + def get_name(self): + return "service object" + + def remove(self): + self.removed = True + + lib_manager_obj = FakeTrackedObject("adb5cb65-8e1d-4a00-b70a-375ea27582f3") + visu_manager_obj = FakeTrackedObject("4d3fdb8f-ab50-4c35-9d3a-d4bb9bb9a628") + import_export.remove_tracked_objects([lib_manager_obj, visu_manager_obj]) + check("import does not remove the library manager", not lib_manager_obj.removed) + check("import does not remove the visualisation manager", not visu_manager_obj.removed) +finally: + shutil.rmtree(workspace) + + +# --- an empty export must never destroy the previous one --------------------- + +# Export Lib To Files on a device project walks nothing (its objects all live +# under Devices), and the empty staging folder then swapped in over the real +# export, wiping it. The swap now refuses an empty staging outright. +import util # noqa: E402 + +workspace = tempfile.mkdtemp() +try: + target = os.path.join(workspace, "Project") + os.mkdir(target) + handle = io.open(os.path.join(target, "KEEP.st"), "w", encoding="utf-8") + handle.write("PROGRAM Keep\n") + handle.close() + + staging = util.begin_export_folder(target) + try: + util.finalize_export_folder(target, staging) + check("an empty export is refused", False, "finalize accepted an empty staging folder") + except util.NothingExportedError as error: + check("an empty export is refused", True) + check("the refusal names the preserved folder", target in str(error)) + check("the previous export survives", os.path.exists(os.path.join(target, "KEEP.st"))) + check("the empty staging folder is cleaned up", not os.path.exists(staging)) + + # A real export must still swap in exactly as before. + staging = util.begin_export_folder(target) + handle = io.open(os.path.join(staging, "NEW.st"), "w", encoding="utf-8") + handle.write("PROGRAM New\n") + handle.close() + util.finalize_export_folder(target, staging) + check("a real export still swaps in", os.path.exists(os.path.join(target, "NEW.st"))) + check("the swap still replaces the old copy", not os.path.exists(os.path.join(target, "KEEP.st"))) +finally: + shutil.rmtree(workspace) + + +# --- a locked folder must not leave last export's files behind --------------- + +# When the folder cannot be renamed the staged files are copied into it +# instead. Copying alone leaves anything the export did not write in place: a +# rendering from a previous export sitting beside a new native xml, describing +# a POU that has since changed, with nothing to say so. +workspace = tempfile.mkdtemp() +try: + target = os.path.join(workspace, "Project") + os.makedirs(os.path.join(target, "application")) + for name in ("application/GONE.txt", "application/GONE.xml"): + handle = io.open(os.path.join(target, name), "w", encoding="utf-8") + handle.write("from the previous export\n") + handle.close() + + staging = util.begin_export_folder(target) + os.makedirs(os.path.join(staging, "application")) + handle = io.open(os.path.join(staging, "application", "STAYS.xml"), "w", encoding="utf-8") + handle.write("from this export\n") + handle.close() + + real_rename = os.rename + + def locked_rename(source, destination): + # Only the folder swap is blocked, which is what a handle on the + # folder does; the files inside it stay writable. + if os.path.isdir(source): + raise OSError("folder is locked") + return real_rename(source, destination) + + os.rename = locked_rename + try: + util.finalize_export_folder(target, staging) + finally: + os.rename = real_rename + + check("the locked path still writes this export", os.path.exists(os.path.join(target, "application", "STAYS.xml"))) + check("a stale rendering is removed", not os.path.exists(os.path.join(target, "application", "GONE.txt"))) + check("a stale source is removed too", not os.path.exists(os.path.join(target, "application", "GONE.xml"))) + check("the staging folder is cleaned up", not os.path.exists(staging)) +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", + "Library Manager.libraries.txt", + "Visualization Manager.service.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) + + +# --- the re-include keeps CODESYS working files out of the repo -------------- + +# .gitignore re-includes the whole of GraphicalTesting/ so that no generic +# pattern drops a POU from the tracked export. That re-include is the last +# pattern to match there, so it also cancelled *.project, *.opt and *.~u, which +# exist because those files carry the controller serial and gateway address, +# and "git add -A" then staged them. Asked of git itself, since only git knows +# which pattern wins. -q, not -v: -v exits 0 on a negated match as well. +# --no-index, because git never reports a path it already tracks as ignored, +# so without it the check on the tracked template could not fail. +def git_ignores(path): + import subprocess + + devnull = open(os.devnull, "w") + try: + return subprocess.call( + ["git", "check-ignore", "-q", "--no-index", path], cwd=REPO, stdout=devnull, stderr=devnull + ) + finally: + devnull.close() + + +try: + have_git = git_ignores(".gitignore") in (0, 1) +except Exception: + have_git = False + +if not have_git: + print("SKIP .gitignore checks: git is not available here") +else: + APPLICATION = "GraphicalTesting/StandardPLC/application" + for path in ( + APPLICATION + "/Main.project", + APPLICATION + "/Main.opt", + APPLICATION + "/Main.project.~u", + APPLICATION + ".codescribe_staging/Main.xml", + APPLICATION + ".codescribe_backup/Main.xml", + ): + check_equal("ignored inside the export: " + path, git_ignores(path), 0) + for path in ( + APPLICATION + "/PLC_PRG.st", + APPLICATION + "/lib/Helper.st", + "GraphicalTesting_template_v1.project", + ): + check_equal("tracked: " + path, git_ignores(path), 1) + +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..2dc2e65 --- /dev/null +++ b/tools/ladder/tests/test_fbd.py @@ -0,0 +1,1070 @@ +# 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, "..")) + +from render import write # noqa: E402 + +import charset # noqa: E402 +import fbd_render # noqa: E402 +import layout # noqa: E402 +from model import Assign, Call, Label, Network, OutputRef, Pou, Signal # noqa: E402 +import parse_fbd # noqa: E402 +import parse_ld # noqa: E402 +import st_render # 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") + + +def box(node): + """The Call a wire reads, unwrapping the pin the wire names. + + A wire that reads a named output pin arrives as an OutputRef around the + call, so that a box read through two pins stays one box. + """ + return node.call if isinstance(node, OutputRef) else node + + +failures = [] + + +def check(name, condition, detail=""): + if condition: + print("OK " + name) + return + failures.append(name) + # The detail quotes rendered lines, which hold box-drawing characters a + # Windows console cannot encode. print() raises UnicodeEncodeError on + # exactly those, which aborts the run at the first golden mismatch - so + # the failures after it are never reported and the suite looks shorter + # than it is rather than looking broken. + sys.stdout.flush() + write(["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")])]))[3], + "(* Network 1: first second * ) third *)", +) +check_equal( + "ST network comments cannot break generated block comments", + st_render.render_pou(Pou("HOSTILE", "program", networks=[Network(hostile_comment, [Signal("x")])]))[3], + "(* Network 1: first second * ) third *)", +) + +# A title is a second field, and can break the block comment just as a +# comment can. With no comment beside it, the title is the heading. +hostile_title = "one *) two" +check_equal( + "network titles cannot break generated block comments", + fbd_render.render_pou(Pou("HOSTILE", "program", networks=[Network("", [Signal("x")], title=hostile_title)]))[3], + "(* Network 1: one * ) two *)", +) + +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") + +# The pin a wire reads is on the wire, not on the box it reads: a box is one +# box however many of its pins are read. +tof_wire = tree2.inputs[1][1] +check("the wire into fbSupplySwitch names its pin", isinstance(tof_wire, OutputRef)) +check_equal("wire leaves TOF on Q", tof_wire.pin, "Q") +tof = tof_wire.call +check_equal("nested TOF", tof.instance_name, "TOF_0") +check("TOF knows Q is read", "Q" in tof.wired_outputs) +gt = box(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)) +# The store hangs off its pin on a wire, outside the box. Written inside, the +# target variable sat among the pin names where it read as another pin. +check( + "art: output assignment hangs off the pin", + any("uiOutVoltage" + U["PIN_R"] + "---> uiCurrSupplyVolt".replace("---", U["H"] * 3) 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) + +# Three, not four: CODESYS stores a jump label on the network it labels, but +# PLCopen exports it as a free-standing element just before that network, so +# it arrives wired to nothing. Counting it as a network of its own put the +# label under its own number and pushed every later number out by one. +check_equal("flow: three networks survive", len(flow.networks), 3) +check_equal("flow: the label is the network's own", flow.networks[2].label, "END") +check( + "flow: the label is not in the network's body", + not any(isinstance(tree, Label) for tree in flow.networks[2].outputs), +) + +# 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", "END:" in flow_st) +check("flow: the label heads the diagram too", "END:" in flow_art) +check("flow: the label is not drawn as a rung", not any("END:" in line and line != "END:" for line in flow_art)) +check("flow: the label is not dressed as a comment", not any("(* label" in line for line in flow_st + flow_art)) + +# 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", box(guard.condition).inputs[0][1].negated, True) +check_equal("flow: negation renders", box(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[2].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" + U["PIN_R"] + U["H"] * 3 + "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. +# The wires differ - each names the pin it reads - but the box behind them is +# one object. +first, second = fan.networks[0].outputs +check("fanout: shared nodes are one object", box(first.source) is box(second.source)) + + +# --- one instance read through two of its pins ------------------------------- + +# A timer whose Q feeds one store and whose ET feeds another. Memoising the +# tree on (localId, pin) made that two timers: two boxes drawn, and two calls +# emitted, so a reader concluded the timer ran twice each cycle. +TWO_PINS = os.path.join(HERE, "fixtures", "36-2-fbd-two-output-pins.xml") +two_pins = parse_fbd.parse_pous(TWO_PINS)[0] +two_pins_st = st_render.render_pou(two_pins) +# The network alone: render_pou would also give us the declaration, where +# "tmr : TON" appears again as the variable it is. +two_pins_art = fbd_render.render_network(two_pins.networks[0]) + +check_equal("two pins: one network", len(two_pins.networks[0].outputs), 2) +check( + "two pins: both stores read the same box", + box(two_pins.networks[0].outputs[0].source) is box(two_pins.networks[0].outputs[1].source), +) +check_equal( + "two pins: the pins are on the wires", + sorted(output.source.pin for output in two_pins.networks[0].outputs), + ["ET", "Q"], +) +check_equal("two pins: the timer is called once", len([l for l in two_pins_st if l.startswith("tmr(")]), 1) +check("two pins: Q is stored", "xQ := tmr.Q;" in two_pins_st) +check("two pins: ET is stored", "tEt := tmr.ET;" in two_pins_st) +check_equal("two pins: one box is drawn", len([l for l in two_pins_art if "tmr : TON" in l]), 1) + +# Two pins are two wires, not one branched wire: a junction column here would +# draw ET and Q as the same signal. +check( + "two pins: Q leaves on its own row", + any(l.rstrip().endswith("> xQ") and "Q" + U["PIN_R"] in l for l in two_pins_art), +) +check( + "two pins: ET leaves on its own row", + any(l.rstrip().endswith("> tEt") and "ET" + U["PIN_R"] in l for l in two_pins_art), +) +check("two pins: no junction between different pins", not any(U["T_DOWN"] in l and "xQ" in l for l in two_pins_art)) + + +# --- one box read by two of a network's outputs ------------------------------ + +# Where both readers hang straight off the box the fan-out draws it once and +# branches. A reader sitting behind another box is a tree of its own, and +# drawing that tree from scratch put a second copy of the same instance on the +# page - two timers where the program has one. This is FB_TESTING network 9 in +# the GraphicalTesting project. +SHARED_BOX = os.path.join(HERE, "fixtures", "fbd_shared_box.plcopen.xml") +shared = parse_fbd.parse_pous(SHARED_BOX)[0] +shared_st = st_render.render_pou(shared) +shared_art = fbd_render.render_network(shared.networks[0]) + +check_equal("shared box: one network", len(shared.networks), 1) +check_equal("shared box: two outputs", len(shared.networks[0].outputs), 2) +check_equal("shared box: the timer is called once", len([l for l in shared_st if l.startswith("fbTimer(")]), 1) +check_equal("shared box: one box is drawn", len([l for l in shared_art if "fbTimer : TON" in l]), 1) +check( + "shared box: both stores are still made", + "xDone := fbTimer.Q;" in shared_st and "xAny := fbTimer.Q OR xManual;" in shared_st, +) + +# The second reader hangs off the pin on a junction, not on a copy of the box +# and not on its name in text: the wire is what says the two readers are the +# same signal. +check("shared box: the pin branches", any(U["T_DOWN"] in l and "xDone" in l for l in shared_art)) +check("shared box: the branch reaches the operator", any(U["BL"] in l and "In1" in l for l in shared_art)) +check("shared box: the box is not named in text", not any("fbTimer.Q" in l for l in shared_art)) + +# An operator has no instance name to refer to, and being stateless it costs +# nothing to draw again - so it is never the box a network is joined around. +check("shared box: the operator is still drawn", any("Out1" in l for l in shared_art)) + + +# --- EN and ENO on an operator box ------------------------------------------- + +# EN decides whether a box runs; it is not one of the things being added. It +# was folded into the operands, so a three-way addition that runs only while +# xEn read as a four-way addition of the enable itself. ENO reports that the +# box ran, and reading it as the box's result made a boolean out of the sum. +enable_st = st_render.render_pou(two_pins) + +check("enable: EN is not an operand", "IF xEn THEN iSum := iA + iB + iC; END_IF" in enable_st) +check("enable: no four-way sum survives", not any("xEn + iA" in line for line in enable_st)) +check("enable: ENO reports the enable", "xSumOk := xEn;" in enable_st) +check( + "enable: the ENO store is not itself guarded", not any(line.startswith("IF xEn THEN xSumOk") for line in enable_st) +) + +# A box with no EN wired keeps its plain expression and no guard. +check("enable: an unguarded operator is unchanged", "xAny := fbTimer.Q OR xManual;" in shared_st) + + +# --- a store that holds: set and reset --------------------------------------- + +# storage="set" on an outVariable was dropped, so a latch rendered as +# "xLatched := xTrip;" - text that says the latch clears the moment its input +# drops, where the program holds it. +latch_st = st_render.render_pou(two_pins) +latch_art = fbd_render.render_network(two_pins.networks[1]) + +check_equal("set: the store is recorded", two_pins.networks[1].outputs[0].storage, "set") +check("set: the ST guards the write", "IF xTrip THEN xLatched := TRUE; END_IF" in latch_st) +check("set: no plain assignment survives", not any("xLatched := xTrip" in line for line in latch_st)) +check("set: the arrow is marked", any("(S)> xLatched" in line for line in latch_art)) + +# The reset counterpart, and the same store written straight onto a block's +# output pin instead of onto a wire. +STORAGE = os.path.join(HERE, "fixtures", "fbd_storage.plcopen.xml") +storage_pou = parse_fbd.parse_pous(STORAGE)[0] +storage_st = st_render.render_pou(storage_pou) +storage_art = fbd_render.render_pou(storage_pou) + +check_equal("reset: the store is recorded", storage_pou.networks[0].outputs[0].storage, "reset") +check("reset: the ST guards the write", "IF xClear THEN xLatched := FALSE; END_IF" in storage_st) +check("reset: the arrow is marked", any("(R)> xLatched" in line for line in storage_art)) + +check_equal("output pin store: recorded on the box", box(storage_pou.networks[1].outputs[0]).stored_outputs["Q"], "set") +check("output pin store: the ST guards the write", "IF tmr.Q THEN xHeld := TRUE; END_IF" in storage_st) +check( + "output pin store: the pin arrow is marked", + any("Q" + U["PIN_R"] + U["H"] * 3 + "(S)> xHeld" in line for line in storage_art), +) + +# str.center splits an odd remainder on opposite sides under CPython 3 and +# IronPython 2.7, so a box title needing odd padding came out one column +# further right from the dev CLI than from the export CODESYS runs. The two +# have to agree on the byte, or every such line is a phantom diff. +check_equal("centred: the odd space goes right", layout.centred("ab", 5), " ab ") +check_equal("centred: an even split is unchanged", layout.centred("ab", 6), " ab ") +check_equal("centred: no room to centre in", layout.centred("abcd", 3), "abcd") +# The box is 15 columns wide for a 10-character title, so the margin and the +# width are both odd - the one case where CPython puts the extra space on the +# other side from IronPython. Both must produce this line. +check( + "centred: a title with odd padding sits where CODESYS puts it", + " pulse : TP" in storage_art, +) + + +# --- edge detection on a block pin ------------------------------------------- + +# edge="rising" on a pin was read by nobody, so a counter that counts once per +# change rendered as one that counts every cycle its input is true. Contacts +# have always carried the marker; the pins had nowhere to put it. +PIN_EDGE = os.path.join(HERE, "fixtures", "36-5-fbd-pin-edge.xml") +pin_edge = parse_fbd.parse_pous(PIN_EDGE)[0] +pin_edge_st = st_render.render_pou(pin_edge) +pin_edge_art = fbd_render.render_network(pin_edge.networks[0]) + +check_equal( + "pin edge: the edge reaches the tree", box(pin_edge.networks[0].outputs[0].source).inputs[0][1].edge, "rising" +) +check("pin edge: the ST shows the trigger", "ctr(CU := R(xPulse), RESET := xRst);" in pin_edge_st) +check("pin edge: the diagram marks the pin", any("R(xPulse)" in line for line in pin_edge_art)) +check("pin edge: an unmarked pin stays unmarked", not any("R(xRst)" in line for line in pin_edge_st)) + + +# --- a network that holds only a comment ------------------------------------- + +# A network with no logic in it was dropped, and its comment then attached to +# the next network - so the file showed one network numbered 1 carrying the +# second network's comment. Every number after a dropped network is wrong, +# and a reviewer opening "Network 5" in CODESYS reads different logic under +# "(* Network 5 *)" in the file. +COMMENT_ONLY = os.path.join(HERE, "fixtures", "36-6-fbd-comment-only-network.xml") +comment_only = parse_fbd.parse_pous(COMMENT_ONLY)[0] +comment_only_art = fbd_render.render_pou(comment_only) + +check_equal("comment-only: two networks", len(comment_only.networks), 2) +check_equal("comment-only: the first has no logic", len(comment_only.networks[0].outputs), 0) +check( + "comment-only: the first keeps its own comment", + comment_only.networks[0].comment.startswith("// Section header: E-STOP CHAIN"), +) +check_equal("comment-only: the second keeps its own", comment_only.networks[1].comment, "// second network comment") +check_equal( + "comment-only: the numbering follows the editor", + [line for line in comment_only_art if line.startswith("(* Network")], + ["(* Network 1: Title of network one *)", "(* Network 2: Title of network two *)"], +) + +# The title is a second field CODESYS draws above the comment, and can be the +# only description a network has. It was skipped with the rest of the +# vendorElements. It heads the network, so it goes on the number line and the +# comment follows it, in the order the editor shows them. +check_equal("comment-only: titles are read", comment_only.networks[0].title, "Title of network one") +check_equal( + "comment-only: the title heads the network and the comment follows", + comment_only_art[comment_only_art.index("(* Network 1: Title of network one *)") + 1], + "(* Section header: E-STOP CHAIN (documentation-only network) *)", +) + + +# --- a shared box read through two different pins ---------------------------- + +# A timer whose ET feeds a store and whose Q feeds an OR box. The readers were +# stacked in the order the export listed them and hung off one junction +# column, so the second reader took whatever row the first left free: the OR +# box was wired from the ET row while the ST said tmr.Q, and with the readers +# the other way round ET was drawn into the Q column. A reader sits level with +# the pin it reads, and readers of different pins get a column each. +TWO_READERS = os.path.join(HERE, "fixtures", "r2-1-fbd-shared-box-q-and-et.xml") +TWO_READERS_SWAPPED = os.path.join(HERE, "fixtures", "r2-1-fbd-shared-box-et-and-q.xml") + + +def wire_rows(art): + """The row of each wire: Q into the OR box, and ET out to its store.""" + q_rows = [row for row, line in enumerate(art) if "Q" + U["PIN_R"] in line] + et_rows = [row for row, line in enumerate(art) if "ET" + U["PIN_R"] in line] + or_rows = [row for row, line in enumerate(art) if U["PIN_L"] + "In1 Out1" in line] + store_rows = [row for row, line in enumerate(art) if line.rstrip().endswith("> tElapsed")] + return q_rows, et_rows, or_rows, store_rows + + +for order, path in (("Q and ET", TWO_READERS), ("ET and Q", TWO_READERS_SWAPPED)): + pou_two = parse_fbd.parse_pous(path)[0] + st_two = st_render.render_pou(pou_two) + art_two = fbd_render.render_network(pou_two.networks[0]) + q_rows, et_rows, or_rows, store_rows = wire_rows(art_two) + + check_equal(order + ": one box is drawn", len([line for line in art_two if "tmr : TON" in line]), 1) + check("st " + order + ": the OR reads Q", "xAny := tmr.Q OR xManual;" in st_two) + check("st " + order + ": the store reads ET", "tElapsed := tmr.ET;" in st_two) + check_equal(order + ": Q leaves the box on one row", len(q_rows), 1) + check_equal(order + ": ET leaves the box on one row", len(et_rows), 1) + # The wire into the OR box is the one leaving Q: the same row, unbroken. + check_equal(order + ": the OR box is wired from the Q row", or_rows, q_rows) + check( + order + ": the Q wire runs straight into the OR box", + "Q" + U["PIN_R"] in art_two[q_rows[0]] + and set(art_two[q_rows[0]].split("Q" + U["PIN_R"])[1].split(U["PIN_L"] + "In1")[0]) == set([U["H"]]), + ) + # The store cannot sit level with ET, because the OR box is in the way; so + # ET's wire turns down a column of its own and the store hangs off that. + check( + order + ": the ET wire turns down its own column", + "ET" + U["PIN_R"] + U["H"] * 2 + U["TR"] in art_two[et_rows[0]], + ) + check_equal(order + ": the store hangs below the ET row", len(store_rows) == 1 and store_rows[0] > et_rows[0], True) + check(order + ": the store is fed from the ET column", art_two[store_rows[0]].lstrip().startswith(U["BL"])) + check(order + ": the ET row does not feed the OR box", U["PIN_L"] + "In1" not in art_two[et_rows[0]]) + check(order + ": no junction between the two pins", not any(U["T_DOWN"] in line for line in art_two)) + +check_equal( + "shared pins: the drawing does not depend on the order of the readers", + fbd_render.render_network(parse_fbd.parse_pous(TWO_READERS)[0].networks[0]), + fbd_render.render_network(parse_fbd.parse_pous(TWO_READERS_SWAPPED)[0].networks[0]), +) + +# An output that reads nothing from the shared box was stacked with the +# readers, and being first it was placed at row 0 - beside the title. It is a +# drawing of its own, and goes below the joined one. +aside_timer = Call("TON", "tmr", inputs=[("IN", Signal("xStart"))], outputs=[("Q", None), ("ET", None)]) +aside_timer.wired_outputs.add("Q") +aside = Network( + "", + [ + Assign("xOther", Signal("xIn")), + Assign("xDone", OutputRef(aside_timer, "Q")), + Assign( + "xAny", + Call( + "OR", + inputs=[("In1", OutputRef(aside_timer, "Q")), ("In2", Signal("xManual"))], + outputs=[("Out1", None)], + ), + ), + ], +) +aside_art = fbd_render.render_network(aside) +check_equal("aside: the title row holds the title alone", aside_art[0].strip(), "tmr : TON") +check("aside: the plain store goes below the joined drawing", aside_art[-1].rstrip().endswith("> xOther")) +check("aside: the joined drawing is still branched", any(U["T_DOWN"] in line and "xDone" in line for line in aside_art)) + + +# --- an EXECUTE box whose ENO pin is wired ----------------------------------- + +# The body was printed only when the network's own output was the EXECUTE +# call. Wire its ENO to a variable and the output is a store, so the box was +# drawn - an empty rectangle - and the two lines of ST inside it were gone. +# The body now sits inside the box, below the pins. +ENO_WIRED = os.path.join(HERE, "fixtures", "r2-2-fbd-execute-eno-wired.xml") +eno = parse_fbd.parse_pous(ENO_WIRED)[0] +eno_st = st_render.render_pou(eno) +eno_art = fbd_render.render_network(eno.networks[0]) + +check("execute eno: the box is drawn", any(line.strip() == "EXECUTE" for line in eno_art)) +check( + "execute eno: the wire to the store is drawn", + any("ENO" + U["PIN_R"] + U["H"] * 3 + "> xRan" in line for line in eno_art), +) +check( + "execute eno: the body is inside the box", + any(U["V"] + " a := 1;" in line for line in eno_art) and any(U["V"] + " b := 2;" in line for line in eno_art), +) +check_equal( + "execute eno: each body line appears once", + [len([l for l in eno_art if "a := 1;" in l]), len([l for l in eno_art if "b := 2;" in l])], + [1, 1], +) +check("execute eno: the box closes below the body", eno_art[-1].strip().startswith(U["BL"])) +check("execute eno: the ST guards the body with EN", "IF xRun THEN" in eno_st and " a := 1;" in eno_st) +check("execute eno: the ENO store reads the enable", "xRan := xRun;" in eno_st) + +# A box behind another box shows its body inside it too, wherever it sits. +deep_execute = Call("EXECUTE", inputs=[("EN", Signal("xRun"))], outputs=[("ENO", None)], st_code=["c := 3;"]) +deep_execute.wired_outputs.add("ENO") +deep = Network( + "", + [ + Assign( + "xBoth", + Call( + "AND", + inputs=[("In1", OutputRef(deep_execute, "ENO")), ("In2", Signal("xOk"))], + outputs=[("Out1", None)], + ), + ) + ], +) +deep_art = fbd_render.render_network(deep) +check("execute deep: a nested box shows its body inside", any(U["V"] + " c := 3;" in line for line in deep_art)) +check_equal("execute deep: the body appears once", len([line for line in deep_art if "c := 3;" in line]), 1) + + +# --- one EXECUTE box read from two places ------------------------------------ + +# An EXECUTE box has no instance name, so a second reader redrew it - and once +# the body moved inside the box, the redraw copied the body too. One box whose +# ENO feeds a store and an AND then showed "nCount := nCount + 1;" twice, which +# reads as a counter that advances by two each scan. The body belongs to the one +# box; a redraw of it must not repeat the statements. +EXECUTE_TWO = os.path.join(HERE, "fixtures", "fbd-execute-two-readers.xml") +execute_two = parse_fbd.parse_pous(EXECUTE_TWO)[0] +execute_two_art = fbd_render.render_network(execute_two.networks[0]) + +check_equal( + "execute two readers: the body is drawn once", + len([line for line in execute_two_art if "nCount := nCount + 1;" in line]), + 1, +) +check("execute two readers: the store is still drawn", any("> xRan" in line for line in execute_two_art)) +check("execute two readers: the AND reader is still drawn", any("> xBoth" in line for line in execute_two_art)) +check( + "execute two readers: the AND reads the box by name", + any("EXECUTE.ENO" + U["H"] * 2 + U["PIN_L"] + "In1" in line for line in execute_two_art), +) + +# The same box feeding a shared timer. The timer's inputs were drawn against a +# fresh set of drawn boxes, so the EXECUTE box inside them was never recorded, +# and the store that also reads it drew the box - and its body - a second time. +EXECUTE_SHARED = os.path.join(HERE, "fixtures", "fbd-execute-feeds-shared-box.xml") +execute_shared = parse_fbd.parse_pous(EXECUTE_SHARED)[0] +execute_shared_art = fbd_render.render_network(execute_shared.networks[0]) + +check_equal( + "execute feeds a shared box: the body is drawn once", + len([line for line in execute_shared_art if "nCount := nCount + 1;" in line]), + 1, +) +check("execute feeds a shared box: the timer is drawn", any("tmr : TON" in line for line in execute_shared_art)) +check( + "execute feeds a shared box: the store reads the box by name", + any("EXECUTE.ENO" + U["H"] * 3 + "> xRan" in line for line in execute_shared_art), +) + +# Two EXECUTE boxes feeding one SUB were both named "EXECUTE.ENO", so the +# export did not say which feeds In1 - and SUB is not commutative. Each is +# numbered in the order the network reaches it, on its title and in the reference. +SUB_FED = os.path.join(HERE, "fixtures", "fbd-two-execute-boxes-feed-one-sub.xml") +SUB_FED_SWAPPED = os.path.join(HERE, "fixtures", "fbd-two-execute-boxes-feed-one-sub-swapped.xml") +sub_fed_art = fbd_render.render_network(parse_fbd.parse_pous(SUB_FED)[0].networks[0]) +sub_fed_swapped_art = fbd_render.render_network(parse_fbd.parse_pous(SUB_FED_SWAPPED)[0].networks[0]) +check( + "numbered execute boxes: titled #1 and #2", + [l.strip() for l in sub_fed_art if l.strip().startswith("EXECUTE #")] == ["EXECUTE #1", "EXECUTE #2"], +) +check( + "numbered execute boxes: In1 names box 1", + any("EXECUTE#1.ENO" + U["H"] * 2 + U["PIN_L"] + "In1" in l for l in sub_fed_art), +) +check( + "numbered execute boxes: In2 names box 2", + any("EXECUTE#2.ENO" + U["H"] * 2 + U["PIN_L"] + "In2" in l for l in sub_fed_art), +) +check("numbered execute boxes: swapping the inputs changes the export", sub_fed_art != sub_fed_swapped_art) +check_equal("numbered execute boxes: each body is drawn once", len([l for l in sub_fed_art if ":=" in l]), 2) +# A lone EXECUTE box read twice keeps its plain name. +check("numbered execute boxes: a lone box is not numbered", not any("#" in l for l in execute_two_art)) + + +# --- a VAR_IN_OUT pin read downstream ---------------------------------------- + +# An in-out pin arrives among the inputs and not the outputs, so a wire read +# from it had no row on the right of the box: it was hung off the first output +# pin's row, and which reader took which row followed set iteration order, so +# one project exported differently from one run to the next. The pin now runs +# through the box to its own row on the right, where its readers attach. +IN_OUT = os.path.join(HERE, "fixtures", "fbd-in-out-pin-read-downstream.xml") +in_out_art = fbd_render.render_network(parse_fbd.parse_pous(IN_OUT)[0].networks[0]) +check( + "in-out pin: its reader leaves on the pin's own row", + any(U["PIN_L"] + "pBuf" in l and l.rstrip().endswith("> arrCopy") for l in in_out_art), +) +check( + "in-out pin: the output's reader stays on the output's row", + any("Q" + U["PIN_R"] in l and l.rstrip().endswith("> xDone") for l in in_out_art), +) +check("in-out pin: the pin runs through the box", any("pBuf" + U["H"] in l and U["PIN_R"] in l for l in in_out_art)) + + +# --- a box with no instance read on two output pins -------------------------- + +# Only an instance box read on two pins went to the joined layout, which gives +# each pin a column. A function read on Q and R went to the single-column +# fan-out, which pushed the R store onto the next free row: the box's bottom +# border, with no tee on R. Any box read through two pins is joined now. +SPLIT = os.path.join(HERE, "fixtures", "fbd-function-read-on-two-output-pins.xml") +split_art = fbd_render.render_network(parse_fbd.parse_pous(SPLIT)[0].networks[0]) +check_equal("two pins, no instance: the box is drawn once", len([l for l in split_art if l.strip() == "F_SPLIT"]), 1) +check("two pins, no instance: R is teed", any("R" + U["PIN_R"] in l for l in split_art)) +check( + "two pins, no instance: R's wire leaves the box on R's row", any("R" + U["PIN_R"] + U["H"] in l for l in split_art) +) +check( + "two pins, no instance: nC is drawn below the Q readers", + [l.rstrip().endswith("> nC") for l in split_art].index(True) + > [l.rstrip().endswith("> xB") for l in split_art].index(True), +) +check("two pins, no instance: no wire leaves the bottom border", not any(U["BR"] + U["H"] in l for l in split_art)) +check_equal( + "two pins, no instance: both Q readers are drawn", + [any(l.rstrip().endswith("> xA") for l in split_art), any(l.rstrip().endswith("> xB") for l in split_art)], + [True, True], +) + + +# --- a box tees every pin something reads ------------------------------------ + +# Here ET is read by two AND boxes whose wires would cross, so both name it in +# text. The pin keeps its tee all the same: it is the one mark that the readers +# take the box's pin, not a variable of the same name. Dropping it made a wired +# ET and an ET read from an input variable export alike. +tee_ctr = Call( + "CTU", + "ctr", + inputs=[("CU", Signal("xPulse")), ("PV", Signal("10"))], + outputs=[("Q", None), ("ET", None), ("CV", None)], + active_output="Q", +) +tee_ctr.wired_outputs.update(["Q", "ET"]) +tee_and1 = Call( + "AND", + inputs=[("In1", OutputRef(tee_ctr, "Q")), ("In2", OutputRef(tee_ctr, "ET"))], + outputs=[("Out1", None)], + active_output="Out1", + wired_outputs=["Out1"], +) +tee_and2 = Call( + "AND", + inputs=[("In1", Signal("v8")), ("In2", Signal("v9"))], + outputs=[("Out1", None)], + active_output="Out1", + wired_outputs=["Out1"], +) +tee_or = Call( + "OR", + inputs=[("In1", tee_and1), ("In2", tee_and2)], + outputs=[("Out1", None)], + active_output="Out1", + wired_outputs=["Out1"], +) +tee_and3 = Call( + "AND", + inputs=[("In1", OutputRef(tee_ctr, "Q")), ("In2", OutputRef(tee_ctr, "ET"))], + outputs=[("Out1", None)], + active_output="Out1", + wired_outputs=["Out1"], +) +tee_art = fbd_render.render_network( + Network("", [Assign("s0", Signal("v7")), Assign("s1", tee_or), Assign("s2", tee_and3)]) +) +check("pin tee: ET is named in text", any("ctr.ET" in l for l in tee_art)) +check("pin tee: ET keeps its connection", any("ET" + U["PIN_R"] in l for l in tee_art)) +check("pin tee: Q shows its connection", any("Q" + U["PIN_R"] in l for l in tee_art)) +wired_twice = fbd_render.render_pou( + parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-timer-pin-wired-to-two-readers.xml"))[0] +) +variable_twice = fbd_render.render_pou( + parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-timer-pin-read-from-a-variable-twice.xml"))[0] +) +check("pin tee: a wired pin and a variable of its name export differently", wired_twice != variable_twice) + +# Outside the joined layout a box keeps a tee on every pin something reads. A +# fan-out draws each of those wires; and where an operator is drawn again, the +# tee on a pin the copy does not use is the one sign that it is the same box +# and not a second one with the same inputs. +two_output_pins = parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "36-2-fbd-two-output-pins.xml"))[0] +fanout_art = fbd_render.render_network(two_output_pins.networks[2]) +check( + "pin tee: a fan-out tees ENO", + any("ENO" + U["PIN_R"] + U["H"] in l and l.rstrip().endswith("> xSumOk") for l in fanout_art), +) +check( + "pin tee: a fan-out tees Out1", + any("Out1" + U["PIN_R"] + U["H"] in l and l.rstrip().endswith("> iSum") for l in fanout_art), +) +one_node = fbd_render.render_pou( + parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-function-read-on-two-pins-one-node.xml"))[0] +) +two_nodes = fbd_render.render_pou( + parse_fbd.parse_pous(os.path.join(HERE, "fixtures", "fbd-function-read-on-two-pins-two-nodes.xml"))[0] +) +check("pin tee: one box read on two pins and two boxes export differently", one_node != two_nodes) + + +# --- one expression reading two pins of a shared box ------------------------- + +# xAlarm := ctr.Q AND (ctr.CV > 5). Each read of the shared box was replaced +# by a marker byte while the branch was composed, and only the first marker +# was resolved: the second stayed in the file as a control character, and +# the CV pin had no wire. Every read is a wire from its own pin now, and the +# file holds no byte below a space. +CONTROL_CHARACTERS = [chr(code) for code in range(1, 32)] + + +def control_free(lines): + return not any(character in line for line in lines for character in CONTROL_CHARACTERS) + + +TWO_PINS_ONE_READER = os.path.join(HERE, "fixtures", "r2-3-fbd-counter-q-and-cv-in-one-expression.xml") +two_reads = parse_fbd.parse_pous(TWO_PINS_ONE_READER)[0] +two_reads_st = st_render.render_pou(two_reads) +two_reads_art = fbd_render.render_network(two_reads.networks[0]) + +check("two reads: no control character in the output", control_free(two_reads_art)) +check("two reads: the ST reads both pins", "xAlarm := ctr.Q AND (ctr.CV > 5);" in two_reads_st) +check_equal("two reads: one box is drawn", len([line for line in two_reads_art if "ctr : CTU" in line]), 1) +check("two reads: the box is not named in text", not any("ctr." in line for line in two_reads_art)) +q_line = [line for line in two_reads_art if "Q" + U["PIN_R"] in line][0] +cv_line = [line for line in two_reads_art if "CV" + U["PIN_R"] in line][0] +check( + "two reads: Q is wired straight into the AND box", + U["PIN_L"] + "In1 Out1" + U["PIN_R"] + U["H"] * 3 + "> xAlarm" in q_line, +) +check("two reads: CV is wired, down a column of its own", "CV" + U["PIN_R"] + U["H"] * 2 + U["TR"] in cv_line) +gt_lines = [line for line in two_reads_art if line.lstrip().startswith(U["BL"]) and U["PIN_L"] + "In1 Out1" in line] +check_equal("two reads: the CV column feeds the GT box", len(gt_lines), 1) +check("two reads: the GT box feeds In2 of the AND box", gt_lines and U["PIN_L"] + "In2" in gt_lines[0]) + +# The same two reads the other way up: (ctr.CV > 5) AND ctr.Q puts the CV +# read above the Q read, where a wire from CV would have to cross the wire +# from Q. Then the first read keeps its wire and the second is named. +crossing_counter = Call( + "CTU", "ctr", inputs=[("CU", Signal("xPulse")), ("PV", Signal("10"))], outputs=[("Q", None), ("CV", None)] +) +crossing_counter.wired_outputs.update(["Q", "CV"]) +crossing_gt = Call( + "GT", + inputs=[("In1", OutputRef(crossing_counter, "CV")), ("In2", Signal("5"))], + outputs=[("Out1", None)], + wired_outputs=["Out1"], +) +crossing_and = Call( + "AND", + inputs=[("In1", crossing_gt), ("In2", OutputRef(crossing_counter, "Q"))], + outputs=[("Out1", None)], + wired_outputs=["Out1"], +) +crossing = Network("", [Assign("xAlarm", crossing_and)]) +crossing_art = fbd_render.render_network(crossing) +check("crossing reads: no control character in the output", control_free(crossing_art)) +check_equal("crossing reads: one box is drawn", len([line for line in crossing_art if "ctr : CTU" in line]), 1) +check( + "crossing reads: the first read is wired", + any("CV" + U["PIN_R"] + U["H"] in line and U["PIN_L"] + "In1 Out1" in line for line in crossing_art), +) +check( + "crossing reads: the second read is named", + any("ctr.Q" + U["H"] * 2 in line and U["PIN_L"] + "In2" in line for line in crossing_art), +) +check("crossing reads: no wire runs from Q", not any("Q" + U["PIN_R"] + U["H"] in line for line in crossing_art)) + + +# --- a fan-out spread across two pins ---------------------------------------- + +# A timer whose Q feeds two stores and whose ET feeds a third. Every reader +# hangs straight off the box, so _shared_source claimed it and _render_fanout +# stacked all three in one column: ET, pushed past Q's second reader, landed +# on the box's bottom border and its wire ran out of the "corner". A shared +# instance read across more than one pin now goes through the joined +# renderer, which gives each pin a column of its own. +TWO_PIN_FANOUT = os.path.join(HERE, "fixtures", "fbd-fanout-two-pins.xml") +tpf = parse_fbd.parse_pous(TWO_PIN_FANOUT)[0] +tpf_st = st_render.render_pou(tpf) +tpf_art = fbd_render.render_network(tpf.networks[0]) + +check_equal("two-pin fanout: one box is drawn", len([l for l in tpf_art if "tmr : TON" in l]), 1) +check_equal("two-pin fanout: the timer is called once", len([l for l in tpf_st if l.startswith("tmr(")]), 1) +check("two-pin fanout: Q reaches both stores", "xA := tmr.Q;" in tpf_st and "xB := tmr.Q;" in tpf_st) +check("two-pin fanout: ET reaches its store", "tC := tmr.ET;" in tpf_st) +# The defect: a wire running straight out of the box's bottom-right corner. +check("two-pin fanout: no wire leaves the bottom border", not any(U["BR"] + U["H"] in l for l in tpf_art)) +# Q teed to two readers, ET on its own row to its store. +check("two-pin fanout: Q branches to two readers", any("Q" + U["PIN_R"] in l and U["T_DOWN"] in l for l in tpf_art)) +check("two-pin fanout: ET turns down its own column", any("ET" + U["PIN_R"] in l and U["TR"] in l for l in tpf_art)) +check("two-pin fanout: every store is reached", all(any(name in l for l in tpf_art) for name in ("xA", "xB", "tC"))) + + +# --- a store written on an operator's output pin ----------------------------- + +# The MOVE-with-EN shape: an operator gated by EN, its result written straight +# onto an output pin. The operator branch returned its expression before the +# output-pin loop, so the store was dropped from the ST - the diagram drew the +# wire, the ST said nothing. The store now appears, guarded by EN. +move = Call("MOVE", inputs=[("EN", Signal("xCond")), ("In", Signal("iSrc"))], outputs=[("ENO", None), ("Out", "iDst")]) +move_st = st_render.network_to_statements(Network("", [move])) +check("operator store: the guarded store is emitted", "IF xCond THEN iDst := MOVE(iSrc); END_IF" in move_st) + +add = Call( + "ADD", + inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], + outputs=[("ENO", None), ("Out1", "iSum")], +) +add_st = st_render.network_to_statements(Network("", [add])) +check("operator store: the sum is stored under its guard", "IF xEn THEN iSum := iA + iB; END_IF" in add_st) + +# A bubble on Out1 while ENO is listed first: the box's active output is ENO, +# so the negation used to be looked for on the wrong pin and lost. The bubble +# is on the pin the reader takes. +neg_out = Call( + "ADD", + inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], + outputs=[("ENO", None), ("Out1", None)], + negated_outputs=set(["Out1"]), + wired_outputs=["Out1"], +) +neg_out_st = st_render.network_to_statements(Network("", [Assign("iSum", OutputRef(neg_out, "Out1"))])) +check( + "operator negate: a negated Out1 inverts though ENO is first", + "IF xEn THEN iSum := NOT (iA + iB); END_IF" in neg_out_st, +) + +# A negated ENO reports the inverse of the enable. +neg_eno = Call( + "ADD", + inputs=[("EN", Signal("xEn")), ("In1", Signal("iA")), ("In2", Signal("iB"))], + outputs=[("ENO", None), ("Out1", None)], + negated_outputs=set(["ENO"]), + wired_outputs=["ENO"], +) +neg_eno_st = st_render.network_to_statements(Network("", [Assign("ok", OutputRef(neg_eno, "ENO"))])) +check("operator negate: a negated ENO inverts the enable", "ok := NOT xEn;" in neg_eno_st) + +# An EXECUTE box whose ENO is stored: the store records that it ran. +exec_eno = Call("EXECUTE", inputs=[("EN", Signal("xRun"))], outputs=[("ENO", "xDid")], st_code=["a := 1;"]) +exec_eno_st = st_render.network_to_statements(Network("", [exec_eno])) +check("execute store: the body is guarded", "IF xRun THEN" in exec_eno_st and " a := 1;" in exec_eno_st) +check("execute store: the ENO store records the run", "xDid := xRun;" in exec_eno_st) + + +# A body line indented with a tab: the tab draws as several columns but counts +# as one character, so it left the box's right wall ragged. Tabs are expanded +# to spaces, and every body row ends its wall in the same column. +tab_box = Call( + "EXECUTE", + inputs=[("EN", Signal("xRun"))], + outputs=[("ENO", "dude")], + st_code=["IF dude THEN", "\twhereismycar := TRUE;", "END_IF"], +) +tab_art = fbd_render.render_network(Network("", [tab_box])) +check("execute tab: no tab survives into the box", not any("\t" in line for line in tab_art)) +check( + "execute tab: the tabbed line is inside the box", + any(U["V"] + " whereismycar := TRUE;" in line for line in tab_art), +) +body_rows = [line for line in tab_art if (U["V"] + " ") in line and line.rstrip().endswith(U["V"])] +check("execute tab: the box has body rows", len(body_rows) >= 3) +check_equal( + "execute tab: every body row's wall ends in one column", len(set(len(line.rstrip()) for line in body_rows)), 1 +) + + +# --- 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..e854a3b --- /dev/null +++ b/tools/ladder/tests/test_ladder.py @@ -0,0 +1,1235 @@ +# 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, "..")) + +from render import write # noqa: E402 + +import charset # noqa: E402 +from ld_render import render_declaration, render_pou # noqa: E402 +from model import COIL, CONTACT, LABEL, Element, Parallel, Series # noqa: E402 +from parse_ld import parse_pous # 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) + return + failures.append(name) + # The detail quotes rendered lines, which hold box-drawing characters a + # Windows console cannot encode. print() raises UnicodeEncodeError on + # exactly those, which aborts the run at the first golden mismatch - so + # the failures after it are never reported and the suite looks shorter + # than it is rather than looking broken. + sys.stdout.flush() + write(["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(' under +# the pin's one connectionPointIn, exactly as it does for a coil. The builder +# read each connection as a pin of its own, so the box drew two IN rows and +# the ST emitted "tmr(IN := xStart, IN := xRun)" - the OR lost, and read as a +# timer with two IN pins. The connections on one pin are now OR'd into it. +import st_render # noqa: E402 + +OR_INTO_PIN = os.path.join(FIXTURES, "ld-or-into-block-pin.xml") +or_pou = parse_pous(OR_INTO_PIN)[0] +or_st = st_render.render_pou(or_pou) +or_art = render_pou(or_pou) + +check("or into pin: the pin reads the OR", any("tmr(IN := (xStart OR xRun));" in line for line in or_st)) +check("or into pin: the timer is called once", len([l for l in or_st if l.strip().startswith("tmr(")]) == 1) +check("or into pin: no duplicate IN argument", not any("IN := xStart, IN := xRun" in line for line in or_st)) +check_equal("or into pin: one box is drawn", len([l for l in or_art if "tmr : TON" in l and ";" not in l]), 1) +# The two contacts are drawn in parallel ahead of the box, not as two pins. +check_equal("or into pin: the box has one IN row", len([l for l in or_art if "IN" in l and U["PIN_L"] in l]), 1) +check("or into pin: the branch is drawn", any(U["T_DOWN"] in l for l in or_art) and any(U["BL"] in l for l in or_art)) +check("or into pin: both contacts are shown", any("xStart" in l for l in or_art) and any("xRun" in l for l in or_art)) + + +# --- logic fidelity ---------------------------------------------------------- + +# Shapes that were dropped or inverted: a rung ending in a jump, its label, a +# negated inVariable on a block pin, and an assignment on a block output pin. +import st_render # noqa: E402 + +LD_FIDELITY = os.path.join(FIXTURES, "ld_fidelity.plcopen.xml") +fidelity_pou = parse_pous(LD_FIDELITY)[0] +fidelity_st = st_render.render_pou(fidelity_pou) +fidelity_art = render_pou(fidelity_pou) + +# The jump rung must survive as a rung at all; the label is the network's, +# not a rung, and is counted with the networks below. +check_equal("fidelity: all six rungs survive", len(fidelity_pou.rungs), 6) + +# A jump's target lives in a "label" attribute; losing it drew ">>?" 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)) +# A jump decides what runs next, so it is written as CODESYS ST writes it, +# like the label it targets - not as a note about the program. +check("fidelity: guarded jump reaches ST", "IF xGo THEN JMP SKIP; END_IF" in fidelity_st) +check("fidelity: no jump is dressed as a comment", not any("(* JMP" in line for line in fidelity_st)) +# A label is the network's own, so it is written under the network's header +# the way the export writes it - and never as a rung. +check("fidelity: label heads its network", "SKIP:" in fidelity_art) +check( + "fidelity: label is not drawn as a rung", not any("SKIP:" in line and U["T_RIGHT"] in line for line in fidelity_art) +) +# A jump target is program structure, not documentation: it is written the +# way ST writes it, and not inside the delimiters this file uses for comments. +check("fidelity: label reaches ST", "SKIP:" in fidelity_st) +check( + "fidelity: the label is not dressed as a comment", + not any("(* label" in line for line in fidelity_st + fidelity_art), +) + +# 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 beside the box", + any("NOT xManual" + U["H"] * 2 + U["PIN_L"] + "RESET" 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)) +# A store below the first row hangs off its pin outside the box. The first +# row carries the rung's own wire onward, so a store there stays inline. +check( + "fidelity: output pin assignment hangs off the pin", + any("CV" + U["PIN_R"] + U["H"] * 3 + "> 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 := NOT tmrA.Q" in line for line in fidelity_st)) +check( + "fidelity: the side pin names what the ST names", + any("NOT tmrA.Q" + U["H"] * 2 + U["PIN_L"] + "RESET" in line for line in fidelity_art), +) + +# The chain feeding a box on a side pin is the BOX's input, not a term of the +# pin's condition. Folding it in ("xB AND NOT tmrA.Q") says the counter also +# resets on NOT xB, and left tmrA with no call at all - a timer that the text +# never runs. +check("fidelity: a side-pin box gets its own call", any("tmrA(IN := xB);" in line for line in fidelity_st)) +check("fidelity: a side-pin box is drawn", any("tmrA : TON" in line for line in fidelity_art)) +check( + "fidelity: the side pin does not absorb the box's input", + not any("xB AND" in line for line in fidelity_st + fidelity_art), +) +check( + "fidelity: the side-pin call comes before the box that reads it", + fidelity_st.index("tmrA(IN := xB);") < fidelity_st.index("ctr2(CU := xGo2, RESET := NOT tmrA.Q);"), +) + +# The same shape with nothing negated and no chain to absorb: an SR latch +# feeding a counter's RESET. The latch was named in the caption and never +# called. +LD_SIDE_PIN = os.path.join(FIXTURES, "ld_side_pin_latch.plcopen.xml") +side_pin_pou = parse_pous(LD_SIDE_PIN)[0] +side_pin_st = st_render.render_pou(side_pin_pou) +side_pin_art = render_pou(side_pin_pou) + +check_equal("side pin: one rung", len(side_pin_pou.rungs), 1) +check("side pin: the latch is called", any("latch(SET1 := xSet, RESET := xClear);" in line for line in side_pin_st)) +check("side pin: the pin reads only the latch output", any("RESET := latch.Q1" in line for line in side_pin_st)) +check( + "side pin: the pin names what the ST names", + any("latch.Q1" + U["H"] * 2 + U["PIN_L"] + "RESET" in line for line in side_pin_art), +) +check("side pin: the latch box is drawn", any("latch : SR" in line for line in side_pin_art)) +check("side pin: no chain folded into the pin", not any("xSet AND" in line for line in side_pin_st + side_pin_art)) + + +# --- one editor network, several outputs ------------------------------------- + +# A timer driving three outputs. One network per sink made it three networks, +# each drawing the box again and calling the timer again, and threw out the +# number of every network after it. CODESYS exports one power rail for the +# whole body, so the rungs cannot be told apart by what they hang off - only +# by what they are connected to. +TWO_COILS = os.path.join(FIXTURES, "36-3-ld-two-coils.xml") +two_coils_pou = parse_pous(TWO_COILS)[0] +two_coils_st = st_render.render_pou(two_coils_pou) +two_coils_art = render_pou(two_coils_pou) + +check_equal("two coils: two networks, not four", len(two_coils_pou.networks), 2) +check_equal("two coils: three outputs in the first", len(two_coils_pou.networks[0].outputs), 3) +check_equal( + "two coils: one header per network", + len([line for line in two_coils_art if line.startswith("(* Network")]), + 2, +) +check_equal("two coils: the timer is called once", len([l for l in two_coils_st if l.startswith("tmr(")]), 1) +check_equal("two coils: one box is drawn", len([l for l in two_coils_art if "tmr : TON" in l]), 1) + +# All three outputs still there, and the second and third name the pin they +# read rather than redrawing the box that produces it. +check("two coils: the plain coil stores", "xOut1 := tmr.Q;" in two_coils_st) +check("two coils: the set coil latches", "IF tmr.Q THEN xOut2 := TRUE; END_IF" in two_coils_st) +check("two coils: the outVariable stores the other pin", "tElapsed := tmr.ET;" in two_coils_st) +check("two coils: a later output names the box", any("[tmr.ET]" in line for line in two_coils_art)) + +# A value feeding a side pin is drawn to the left of the box on a wire into +# the pin, the way the editor draws it. Written inside as "PT := T#2S" it +# reads as part of the pin name, and widens the box by every value in it. +check( + "two coils: the pin value sits outside the box", + any("T#2S" + U["H"] * 2 + U["PIN_L"] + "PT" in line for line in two_coils_art), +) +check("two coils: no value is left inside a box", not any(" := " in line for line in two_coils_art)) + +# The same shape in a real SP11 export: LDTesting with one coil added to its +# first network. +SP11_TWO_COILS = os.path.join(FIXTURES, "36-3-ld-two-coils-sp11.xml") +sp11 = parse_pous(SP11_TWO_COILS)[0] +sp11_st = st_render.render_pou(sp11) + +check_equal("sp11 two coils: two networks, not three", len(sp11.networks), 2) +check_equal("sp11 two coils: both coils in network 1", len(sp11.networks[0].outputs), 2) +check("sp11 two coils: the added coil is kept", any("Lamp :=" in line for line in sp11_st)) +check_equal("sp11 two coils: the timer is called once", len([l for l in sp11_st if l.startswith("TON_0(")]), 1) + + +# --- an EXECUTE box on a rung ------------------------------------------------ + +# An EXECUTE box has no body of its own: the whole of it is inline ST in an +# addData element. The FBD renderer has always drawn it; the ladder one drew +# an empty rectangle and dropped every line of the logic. +LD_EXECUTE = os.path.join(FIXTURES, "ld_execute.plcopen.xml") +execute_pou = parse_pous(LD_EXECUTE)[0] +execute_st = st_render.render_pou(execute_pou) +execute_art = render_pou(execute_pou) + +check_equal("execute: the inline ST is read", len(execute_pou.networks[0].outputs[0].items[-1].st_code), 4) +check("execute: the diagram shows the body", any("iCount := iCount + 1;" in line for line in execute_art)) +check("execute: the body is inside the box", any(U["V"] + " iCount := iCount + 1;" in line for line in execute_art)) +check("execute: the box is still drawn", any("EXECUTE" in line for line in execute_art)) +# The rung condition is what decides whether the box runs, so it guards the +# body rather than being dropped for looking redundant. +check("execute: the ST guards the body with the rung", "IF xRun THEN" in execute_st) +check("execute: the body reaches the ST", any("iCount := iCount + 1;" in line for line in execute_st)) +check("execute: no call to a box with no body", not any(line.startswith("EXECUTE(") for line in execute_st)) + + +# --- edge detection on a block pin ------------------------------------------- + +# A contact has always carried its P; the block pin the rung's power enters +# through had nowhere to put one, so a counter that counts once per change +# rendered as one that counts every cycle its input is true. +PIN_EDGE = os.path.join(FIXTURES, "36-5-ld-pin-edge.xml") +pin_edge_pou = parse_pous(PIN_EDGE)[0] +pin_edge_st = st_render.render_pou(pin_edge_pou) +pin_edge_art = render_pou(pin_edge_pou) + +check("ld pin edge: the ST shows the trigger", "ctr(CU := R(xCount), RESET := xRst, PV := 10);" in pin_edge_st) +check("ld pin edge: the box wall carries the marker", any("PCU" in line for line in pin_edge_art)) +check("ld pin edge: an unmarked pin stays unmarked", not any("R(xRst)" in line for line in pin_edge_st)) + +# The contact form, which already worked, must keep working: same spelling in +# the ST, same letter in the diagram. +check("ld pin edge: a contact still triggers", "xEdge := R(xA);" in pin_edge_st) +check( + "ld pin edge: a contact still draws its P", + any(U["CONTACT_L"] + "P" + U["CONTACT_R"] in line for line in pin_edge_art), +) + + +# --- network comments, and a network that holds only one --------------------- + +# An LD network's comment never reached its header: the parser did not know +# the element existed, so every header read a bare "(* Network n *)". +LD_COMMENT = os.path.join(FIXTURES, "36-6-ld-comment.xml") +ld_comment_pou = parse_pous(LD_COMMENT)[0] +ld_comment_art = render_pou(ld_comment_pou) + +check_equal("ld comment: two networks", len(ld_comment_pou.networks), 2) +check_equal("ld comment: the comment is read", ld_comment_pou.networks[1].comment, "XXX - WARNING: Timer then counter") +check("ld comment: it reaches the header", "(* Network 2: XXX - WARNING: Timer then counter *)" in ld_comment_art) + +# A network holding nothing but a comment was dropped, and every network after +# it renumbered. +LD_EMPTY = os.path.join(FIXTURES, "36-6-ld-empty-network.xml") +ld_empty_pou = parse_pous(LD_EMPTY)[0] +ld_empty_art = render_pou(ld_empty_pou) + +check_equal("ld empty: three networks", len(ld_empty_pou.networks), 3) +check_equal("ld empty: the middle one has no rungs", len(ld_empty_pou.networks[1].outputs), 0) +check_equal( + "ld empty: the numbering follows the editor", + [line for line in ld_empty_art if line.startswith("(* Network")], + ["(* Network 1 *)", "(* Network 2: SECTION: safety interlocks *)", "(* Network 3 *)"], +) +# A header with nothing under it: the number is occupied, the body is empty, +# and the next network carries the next number. +empty_at = ld_empty_art.index("(* Network 2: SECTION: safety interlocks *)") +check_equal("ld empty: the comment-only network has no body", ld_empty_art[empty_at + 1], "") +check_equal("ld empty: Network 3 follows it", ld_empty_art[empty_at + 2], "(* Network 3 *)") +check_equal("ld empty: and holds the timer", len(ld_empty_pou.networks[2].outputs), 1) + +# A jump label is stored on its network in CODESYS but exported just before +# it, wired to nothing. Counting it as a network of its own put it under a +# number of its own and pushed every later number out by one; carrying it +# into the network's body drew it as a rung, and twice over once the native +# export supplied the label as well. It is the network's label, nothing else. +check_equal("fidelity: six networks, not seven", len(fidelity_pou.networks), 6) +check_equal("fidelity: the label is the network's own", fidelity_pou.networks[1].label, "SKIP") +check_equal("fidelity: and is not in its body", len(fidelity_pou.networks[1].outputs), 1) +check( + "fidelity: no label element is left in any body", + not any( + isinstance(rung, Element) and rung.kind == LABEL + for network in fidelity_pou.networks + for rung in network.outputs + ), +) + +# 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)) + + +# --- an EXECUTE box's EN pin bubble and edge in ST --------------------------- + +# The EXECUTE branch of the LD walker used the rung condition raw, skipping +# the bubble and the P or N that every other block's power pin gets. A negated +# EN then read as an unguarded run, and a bare rail with a negated EN as an +# unconditional one - the exact inverse of when the program runs the code. +import st_render # noqa: E402 + + +def _execute(power_negated=False, power_edge=None): + return Element( + kind="block", + type_name="EXECUTE", + input_pins=[("EN", None)], + output_pins=[("ENO", None)], + st_code=["a := 1;"], + power_negated=power_negated, + power_edge=power_edge, + active_output="ENO", + ) + + +def _contact(name): + return Element(kind=CONTACT, label=name) + + +neg = st_render.rung_to_statements(Series([_contact("xRun"), _execute(power_negated=True)])) +check("execute EN: a negated EN inverts the guard", "IF NOT xRun THEN" in neg) +edge = st_render.rung_to_statements(Series([_contact("xRun2"), _execute(power_edge="rising")])) +check("execute EN: an edge EN triggers on the change", "IF R(xRun2) THEN" in edge) +bare = st_render.rung_to_statements(_execute(power_negated=True)) +check("execute EN: a bare rail with a negated EN never runs it", "IF NOT TRUE THEN" in bare) +check("execute EN: a bare negated EN is not emitted unconditionally", bare[0] != "a := 1;") + + +# --- a stateless operator read by more than one rung ------------------------- + +# An operator whose output feeds two coils is not named ("OR.Out1" points at +# no variable) and not drawn twice: the two rungs share the box, so they are +# merged into one wire that branches after it, the box drawn once. The ladder +# path used to name the second read. +OPERATOR_ACROSS = os.path.join(FIXTURES, "ld-operator-across-rungs.xml") +opr_pou = parse_pous(OPERATOR_ACROSS)[0] +opr_art = render_pou(opr_pou) + +check("operator reuse: the operator is not named as an instance", not any("OR.Out1" in line for line in opr_art)) +check("operator reuse: the operator is not referenced in brackets", not any("[OR" in line for line in opr_art)) +check_equal("operator reuse: the shared box is drawn once", len([l for l in opr_art if "In1 Out1" in l]), 1) +check("operator reuse: the readers branch off the box", any(U["T_DOWN"] in l for l in opr_art)) +check( + "operator reuse: both coils are still driven", any("xA" in l for l in opr_art) and any("xB" in l for l in opr_art) +) + + +# --- a head shared by several branches is drawn once ------------------------- + +# The parser builds one branch per sink, so a contact chain or a block that +# feeds several sinks was repeated down every branch - drawn again and again, +# which read as separate rungs rather than one wire that branches after a +# shared head. The diagram now pulls the common prefix out in front, the way +# the editor draws it. +SHARED_PREFIX = os.path.join(FIXTURES, "ld-shared-prefix-branches.xml") +shared_pou = parse_pous(SHARED_PREFIX)[0] +shared_art = render_pou(shared_pou) + +# xGo feeds two coils. It is drawn once, then the wire branches to each coil. +check_equal("shared prefix: the shared contact is drawn once", len([l for l in shared_art if "xGo" in l]), 1) +check( + "shared prefix: both coils are still drawn", + any("xA" in l for l in shared_art) and any("xB" in l for l in shared_art), +) +check("shared prefix: the wire branches after the contact", any(U["T_DOWN"] in l for l in shared_art)) +# The two coils sit on their own rows, one per branch. +xa_row = [i for i, l in enumerate(shared_art) if "xA" in l][0] +xb_row = [i for i, l in enumerate(shared_art) if "xB" in l][0] +check("shared prefix: the coils are on different rows", xa_row != xb_row) +# Each coil is a rung end that reaches the right rail on its own; the branch +# splits on the left and never rejoins on the right. +check("shared prefix: neither coil rejoins the other", not any(U["BR"] in l for l in shared_art)) +check_equal( + "shared prefix: each coil reaches the right rail", + len([l for l in shared_art if "( )" in l and l.rstrip().endswith(U["T_LEFT"])]), + 2, +) + + +# --- separate sinks that share a box are one branched rung ------------------- + +# A box whose output feeds two sinks reaches each through its own sink, so the +# parser builds a rung apiece and each redraws the box. That reads as two +# separate networks when it is one wire that splits after the box - the shape +# a RETURN and a coil sharing a block's ENO make. The rungs are now merged: +# the box is drawn once and the readers branch off it. +SHARED_BOX_SINKS = os.path.join(FIXTURES, "ld-return-and-coil-share-a-box.xml") +shared_box_pou = parse_pous(SHARED_BOX_SINKS)[0] +shared_box_art = render_pou(shared_box_pou) + +check_equal("shared box sinks: the box is drawn once", len([l for l in shared_box_art if "In2 Out2" in l]), 1) +check_equal( + "shared box sinks: one rung header, one network", len([l for l in shared_box_art if l.startswith("(* Network")]), 1 +) +check("shared box sinks: the box output branches", any(U["T_DOWN"] in l for l in shared_box_art)) +check("shared box sinks: the RETURN is drawn", any("" in l for l in shared_box_art)) +check("shared box sinks: the coil branch is drawn", any("( )" in l for l in shared_box_art)) +check( + "shared box sinks: the edge contact is on the coil branch", + any(U["CONTACT_L"] + "P" + U["CONTACT_R"] in l for l in shared_box_art), +) +# The two sinks are on their own rows, not stacked into one. +return_row = [i for i, l in enumerate(shared_box_art) if "" in l][0] +coil_row = [i for i, l in enumerate(shared_box_art) if "( )" in l][0] +check("shared box sinks: RETURN and the coil are on different rows", return_row != coil_row) +# The return and the coil are two rung ends: each reaches the right rail on +# its own row (the box's own corner aside, nothing rejoins into one wire). +check("shared box sinks: the return reaches the right rail", shared_box_art[return_row].rstrip().endswith(U["T_LEFT"])) +check("shared box sinks: the coil reaches the right rail", shared_box_art[coil_row].rstrip().endswith(U["T_LEFT"])) + + +# --- a contact reads a block output without naming the pin ------------------- + +# CODESYS often wires a contact to a block's output without naming the pin, so +# the connection carries no formalParameter. That read still consumes the +# block, so its output must tee - and the box must build the same whether the +# reader named the pin or not, or two rungs that share the box (a RETURN and a +# coil off one ENO) would not line up and would not merge. This is the shape a +# real Network 5 makes. +UNNAMED = os.path.join(FIXTURES, "ld-contact-reads-block-unnamed-pin.xml") +unnamed_pou = parse_pous(UNNAMED)[0] +unnamed_art = render_pou(unnamed_pou) + +check_equal("unnamed pin: the box is drawn once", len([l for l in unnamed_art if "In2 Out2" in l]), 1) +check_equal("unnamed pin: one network, one header", len([l for l in unnamed_art if l.startswith("(* Network")]), 1) +check("unnamed pin: the box output branches", any(U["T_DOWN"] in l for l in unnamed_art)) +check("unnamed pin: the consumed output is teed", any("ENO" + U["PIN_R"] in l for l in unnamed_art)) +check("unnamed pin: no untee'd wire runs from the box", not any("ENO" + U["V"] + U["H"] in l for l in unnamed_art)) +check( + "unnamed pin: RETURN and the coil are both drawn", + any("" in l for l in unnamed_art) and any("( )" in l for l in unnamed_art), +) + + +# --- elements are the same only when they are the same node ------------------ + +# The rung merge and the shared-prefix factoring decided that two elements were +# one by comparing what they draw. Two contacts on one variable, or two boxes of +# one type with one body, draw the same and are still two elements in the +# editor. Treating them as one deleted the second coil of a double coil, and +# fused two EXECUTE boxes into one. Sameness is the node's localId now. +DOUBLE_COIL = os.path.join(FIXTURES, "ld-double-coil.xml") +double_coil_art = render_pou(parse_pous(DOUBLE_COIL)[0]) +check_equal("double coil: both coils are drawn", len([l for l in double_coil_art if "( )" in l]), 2) +check_equal("double coil: both contacts are drawn", len([l for l in double_coil_art if "xGo" in l]), 2) +check_equal( + "double coil: each coil reaches the right rail", + len([l for l in double_coil_art if "( )" in l and l.rstrip().endswith(U["T_LEFT"])]), + 2, +) + +ALIKE = os.path.join(FIXTURES, "ld-two-execute-boxes-alike.xml") +alike_art = render_pou(parse_pous(ALIKE)[0]) +check_equal( + "alike boxes: two EXECUTE boxes are drawn", len([l for l in alike_art if l.strip(U["V"] + " ") == "EXECUTE"]), 2 +) +check_equal("alike boxes: each body is drawn", len([l for l in alike_art if "nCount := nCount + 1;" in l]), 2) + +# The control: one EXECUTE box read by two coils is still one box, one body. +EXECUTE_TWO_COILS = os.path.join(FIXTURES, "ld-execute-two-coils.xml") +execute_two_coils_art = render_pou(parse_pous(EXECUTE_TWO_COILS)[0]) +check_equal( + "one execute, two coils: one box", len([l for l in execute_two_coils_art if l.strip(U["V"] + " ") == "EXECUTE"]), 1 +) +check_equal( + "one execute, two coils: the body once", len([l for l in execute_two_coils_art if "nCount := nCount + 1;" in l]), 1 +) +check( + "one execute, two coils: both coils", + any("xA" in l for l in execute_two_coils_art) and any("xB" in l for l in execute_two_coils_art), +) + +# One box read on two different pins was redrawn for the second pin, because an +# operator has no instance to name, and the copies differ in their active pin so +# nothing merged them. The ADD box was drawn twice: two additions for one. +TWO_PINS = os.path.join(FIXTURES, "ld-operator-read-on-two-pins.xml") +two_pins_art = render_pou(parse_pous(TWO_PINS)[0]) +check_equal("operator on two pins: the box is drawn once", len([l for l in two_pins_art if "In3" in l]), 1) +check("operator on two pins: the coil is drawn", any("xDone" in l for l in two_pins_art)) +check("operator on two pins: the store is drawn", any("iSum" in l for l in two_pins_art)) +check( + "operator on two pins: the coil reads the box's ENO by name", + any("[ADD.ENO]" in l and "( )" in l for l in two_pins_art), +) + +# Named by type alone, two ADD boxes in one network were indistinguishable: +# "[ADD.ENO]" twice, and moving a coil from one box to the other changed +# nothing in the export. Where the text names one of two or more unnamed boxes +# of a type, each is numbered in the order it is built, on its title and in +# every reference to it. +TWO_OPERATORS = os.path.join(FIXTURES, "ld-two-operators-each-read-on-two-pins.xml") +TWO_OPERATORS_SWAPPED = os.path.join(FIXTURES, "ld-two-operators-each-read-on-two-pins-swapped.xml") +two_operators_art = render_pou(parse_pous(TWO_OPERATORS)[0]) +two_operators_swapped_art = render_pou(parse_pous(TWO_OPERATORS_SWAPPED)[0]) +check("numbered boxes: the first box is titled #1", any(l.strip(U["V"] + " ") == "ADD #1" for l in two_operators_art)) +check("numbered boxes: the second box is titled #2", any(l.strip(U["V"] + " ") == "ADD #2" for l in two_operators_art)) +check_equal( + "numbered boxes: each coil names its own box", + [ + ("[ADD#1.ENO]" in two_operators_art[i + 1], "[ADD#2.ENO]" in two_operators_art[i + 1]) + for i, l in enumerate(two_operators_art[:-1]) + if l.strip(U["V"] + " ") in ("xA", "xB") + ], + [(True, False), (False, True)], +) +check("numbered boxes: no reference is left unnumbered", not any("[ADD.ENO]" in l for l in two_operators_art)) +check( + "numbered boxes: swapping the coils between the boxes changes the export", + two_operators_art != two_operators_swapped_art, +) +# A box the text names only inside a side pin's caption - here through a +# parallel branch into a counter's RESET - is named all the same, so it is +# numbered too. It was not, and rewiring the reset from one AND box to the +# other changed nothing in the export. +IN_CAPTION = os.path.join(FIXTURES, "ld-operator-named-in-a-nested-pin-caption.xml") +IN_CAPTION_SWAPPED = os.path.join(FIXTURES, "ld-operator-named-in-a-nested-pin-caption-swapped.xml") +in_caption_art = render_pou(parse_pous(IN_CAPTION)[0]) +in_caption_swapped_art = render_pou(parse_pous(IN_CAPTION_SWAPPED)[0]) +check( + "numbered in a caption: both boxes are numbered", + any(l.strip(U["V"] + " ") == "AND #1" for l in in_caption_art) + and any(l.strip(U["V"] + " ") == "AND #2" for l in in_caption_art), +) +check( + "numbered in a caption: the caption names a numbered box", + any("RESET" in l and "AND#1.Out1" in l for l in in_caption_art), +) +check( + "numbered in a caption: moving the reset to the other box changes the export", + in_caption_art != in_caption_swapped_art, +) + +# A single box of a type is not numbered, and neither are two that the text never names. +check("numbered boxes: a lone box keeps its plain name", not any("#" in l for l in two_pins_art)) +check("numbered boxes: boxes the text never names are not numbered", not any("#" in l for l in alike_art)) + + +# --- one box in the editor is one box in the drawing ------------------------- + +# The parser builds a copy of a box for every rung that reaches it, and relies on +# the rung merge and the shared-prefix factoring to draw the copies as one. Where +# they cannot fuse - the copies sit in rungs of different shapes, two readers +# hoist one box into a side pin, or a hoisted box is also read on a rung - the +# box was drawn once per copy. The first copy in drawing order is now drawn, and +# each later copy is named by the pin it reads, without the wire that powers it: +# that wire belongs to the box already drawn. +def title_rows(art, title): + """How many box titles in ``art`` read exactly ``title``. + + Titles of boxes side by side share a row, separated by runs of spaces. + """ + import re + + count = 0 + for line in art: + for part in re.split(r"\s{2,}", line.strip(U["V"] + " ")): + if part == title: + count += 1 + return count + + +def box_art(name): + return render_pou(parse_pous(os.path.join(FIXTURES, name))[0]) + + +hoisted_two_art = box_art("ld-hoisted-operator-two-readers.xml") +check_equal("one box: a box hoisted by two readers is drawn once", title_rows(hoisted_two_art, "MUL"), 1) + +hoisted_then_art = box_art("ld-hoisted-operator-then-read-on-its-pin.xml") +check_equal("one box: a box on a rung and hoisted into a pin is drawn once", title_rows(hoisted_then_art, "ADD"), 1) +check("one box: the rung's store is still drawn", any("iSum" in l for l in hoisted_then_art)) +check( + "one box: the pin still reads the box", + any("ADD.Out1" + U["H"] * 2 + U["PIN_L"] + "In2" in l for l in hoisted_then_art), +) + +shapes_art = box_art("ld-operator-read-by-two-rung-shapes.xml") +check_equal("one box: a box read by rungs of two shapes is drawn once", title_rows(shapes_art, "ADD"), 1) +check("one box: the second rung reads the box by name", any("[ADD.ENO]" in l for l in shapes_art)) +check("one box: both coils are drawn", any("xA" in l for l in shapes_art) and any("xB" in l for l in shapes_art)) +check("one box: the parallel contact is kept", any("xK" in l for l in shapes_art)) + +or_art = box_art("ld-hoisted-or-under-two-coils.xml") +check_equal("one box: a hoisted OR under two coils is drawn once", title_rows(or_art, "OR"), 1) +check_equal("one box: the box that reads it is drawn once", title_rows(or_art, "AND"), 1) + +exec_mixed_art = box_art("ld-execute-read-by-a-coil-and-a-contact.xml") +check_equal( + "one box: two EXECUTE boxes are drawn", + title_rows(exec_mixed_art, "EXECUTE #1") + title_rows(exec_mixed_art, "EXECUTE #2"), + 2, +) +check_equal("one box: the first body once", len([l for l in exec_mixed_art if "nA := nA + 1;" in l]), 1) +check_equal("one box: the second body once", len([l for l in exec_mixed_art if "nB := nB + 1;" in l]), 1) +check( + "one box: the contact's rung reads the first box by name", + any("[EXECUTE#1.ENO]" in l and "xK" not in l for l in exec_mixed_art), +) +check_equal("one box: the contact feeding both boxes is drawn once", len([l for l in exec_mixed_art if "xGo" in l]), 1) +check_equal( + "one box: every coil still reaches the right rail", + len([l for l in exec_mixed_art if "( )" in l and l.rstrip().endswith(U["T_LEFT"])]), + 3, +) + +# The copy of a box that is kept must be the first one built. Only that copy +# carries the instance boxes upstream of it: every later copy names them, as +# "[tmr.Q]". Keeping the first copy drawn instead deleted the timer, its contact +# and its preset from the export, and two programs that differ only in the +# preset exported identically. +behind_timer_art = box_art("ld-box-on-rung-and-in-side-pin-behind-a-timer.xml") +behind_timer_pt10_art = box_art("ld-box-on-rung-and-in-side-pin-behind-a-timer-pt10.xml") +check_equal("first build: the timer is drawn", title_rows(behind_timer_art, "tmr : TON"), 1) +check("first build: the timer's contact is drawn", any("xGo" in l for l in behind_timer_art)) +check("first build: the timer's preset is drawn", any("T#5S" in l for l in behind_timer_art)) +check_equal("first build: the box behind it is drawn once", title_rows(behind_timer_art, "GT"), 1) +check("first build: a different preset changes the export", behind_timer_art != behind_timer_pt10_art) +check_equal( + "first build: a later rung merged forward keeps the timer", + title_rows(box_art("ld-box-behind-a-timer-read-by-a-later-rung.xml"), "tmr : TON"), + 1, +) +check_equal( + "first build: a later rung's hoist keeps the timer", + title_rows(box_art("ld-box-behind-a-timer-hoisted-by-a-later-rung.xml"), "tmr : TON"), + 1, +) + +# Naming one repeated box can break the fusion of the box after it: the copies +# of MUL no longer shared a head once one copy of ADD was named, and MUL was +# drawn twice, exporting like a program with two MUL boxes. The repeats are +# found again until nothing new is drawn twice. +behind_repeat_art = box_art("ld-box-feeding-two-coils-behind-a-repeated-box.xml") +check_equal("repeats: the box behind a named box is drawn once", title_rows(behind_repeat_art, "MUL"), 1) +check_equal("repeats: the named box is drawn once", title_rows(behind_repeat_art, "ADD"), 1) +check( + "repeats: one MUL and two MUL boxes export differently", + behind_repeat_art != box_art("ld-box-feeding-two-coils-behind-a-repeated-box-two-nodes.xml"), +) + + +def label_count(art, name): + """How many times a contact or coil label appears in ``art``.""" + import re + + return sum(len(re.findall(r"(?= 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, + "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)[1], "PROGRAM PLAIN") +check("the fallback says it is one", render_declaration(structured_pou)[0].startswith("(* Declaration rebuilt")) +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..0c9e8d2 --- /dev/null +++ b/tools/ladder/tests/test_xmlbackend.py @@ -0,0 +1,213 @@ +# 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, "..")) + +from render import write # noqa: E402 + +import plcopen # noqa: E402 +import xmlbackend # noqa: E402 + +FIXTURES = os.path.join(HERE, "fixtures") +CODESYS = os.path.join(FIXTURES, "codesys") + +failures = [] + + +def astral_reference(): + """A character outside the Basic Multilingual Plane, as one reference. + + IronPython holds strings as UTF-16, so such a character is a surrogate + pair, and encoding it a unit at a time writes two numeric references for + one character. A lone surrogate is not a legal XML character: System.Xml + accepts it, expat rejects the whole document - an export that parses + inside CODESYS and nowhere else. + + Written as the UTF-8 bytes a real file holds, so this source stays ASCII + and loads under both interpreters. + """ + data = b"hi \xf0\x9f\x98\x80 there" + return plcopen._to_ascii(data) + + +def check(name, condition, detail=""): + if condition: + print("OK " + name) + return + failures.append(name) + # See test_fbd: a detail quoting rendered lines cannot go through print() + # on a Windows console without aborting the run. + sys.stdout.flush() + write(["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) + +# U+1F600 is one character; it must come back as one reference, not as the +# two surrogates it is stored as. +check_equal("an astral character is one reference", astral_reference(), b"hi 😀 there") +check("no lone surrogate reaches the parser", b"�" not in astral_reference()) + +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) diff --git a/tools/ladder/write_st.py b/tools/ladder/write_st.py new file mode 100644 index 0000000..e953d5a --- /dev/null +++ b/tools/ladder/write_st.py @@ -0,0 +1,108 @@ +# REMEMBER: this must stay valid under IronPython 2.7 as well as Python 3. +"""Write the equivalent-ST rendering of a graphical POU beside its diagram. + + python tools/ladder/write_st.py [...] + +The export does not write these. It did for a while, as ".st.txt" next +to ".txt", and that is easy to turn back on - see below - but for now +the diagram is the only rendering the export produces, and this script is how +to get the ST when it is wanted. + +The ST states the logic exactly where the diagram can only approximate it: a +block read through two of its output pins is one call, and no single-wire +diagram can say so. It is a rendering, not a translation - not guaranteed to +compile, and never to be fed back into CODESYS - so every file it writes +opens by saying that. + +Input is PLCopen xml, which is what the renderer reads. To get some from a +project, call graphical_export._export_plcopen(obj, path) from a CODESYS +script; codesys-headless-test.md shows how to run one. + +To put this back on the export path, in graphical_export.py: + + * import st_render + * have render_plcopen return the ST lines alongside the diagram lines, + built the same way as the diagram - notes first, then one + st_render.render_pou(pou) per POU, joined by _joined + * in write_rendered_text, write them to base_path + ".st.txt" with + ST_HEADER at the top, and remove that file alongside the diagram when a + write fails + +The suffix has to stay ".st.txt" rather than ".st": import_from_files +dispatches on ".xml" and ".st", and os.path.splitext sees ".txt" for this +one, so it is ignored by construction the way the diagram is. +""" + +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")) + +from render import write # noqa: E402 + +import parse_fbd # noqa: E402 +import parse_ld # noqa: E402 +import plcopen # noqa: E402 +import st_render # noqa: E402 + +# Language -> parser. Kept here rather than imported from graphical_export, +# which needs the CODESYS scriptengine module to load at all. +PARSERS = {parse_ld.LANGUAGE: parse_ld, parse_fbd.LANGUAGE: parse_fbd} + +# Stated in the file itself, not just in the docs. The ST reads like source +# and sits next to real .st exports, so the one thing a reader must not +# assume is that it can go back into CODESYS. +ST_HEADER = [ + "(* Equivalent Structured Text for a graphical POU, written by codescribe.", + " READ ONLY. This is a rendering of the native xml beside it, not a", + " translation: it is not guaranteed to compile and must never be imported", + " or pasted back into CODESYS. The native xml is the source. *)", + "", +] + +SUFFIX = ".st.txt" + + +def render(path): + """The ST lines for every renderable POU in a PLCopen file.""" + lines = [] + for pou_elem, language, body in plcopen.iter_bodies(path): + parser = PARSERS.get(language) + if parser is None: + continue + lines.extend(st_render.render_pou(parser.pou_from_body(pou_elem, body))) + lines.append("") + while lines and lines[-1] == "": + lines.pop() + return lines + + +def main(argv): + if not argv: + write(__doc__.strip().split("\n")[:3]) + return 2 + for path in argv: + lines = render(path) + if not lines: + print("nothing renderable in " + path) + continue + base = path + for ending in (".plcopen.xml", ".xml"): + if base.endswith(ending): + base = base[: -len(ending)] + break + target = base + SUFFIX + handle = open(target, "wb") + try: + handle.write(("\n".join(ST_HEADER + lines) + "\n").encode("utf-8")) + finally: + handle.close() + print("wrote " + target) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))