@aryacode/dsh-engineering 0.4.7 → 0.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +202 -0
- package/README.md +40 -26
- package/cordis.patch.yml +7 -6
- package/entries/config.js +62 -0
- package/entries/ftview.js +5 -4
- package/entries/l5x.js +47 -15
- package/entries/mwi.js +26 -6
- package/entries/optix.js +105 -0
- package/lib/client.js +335 -6
- package/package.json +11 -10
- package/vendor/ftview/SKILL.md +17 -0
- package/vendor/l5x/SKILL.md +17 -0
- package/vendor/l5x/studio-bridge/NuGet.Config +1 -1
- package/vendor/mwi/README.md +41 -18
- package/vendor/mwi/SKILL.md +39 -20
- package/vendor/mwi/bridge/mw_bridge.ps1 +2768 -2763
- package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -30
- package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -22
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -339
- package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -765
- package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -399
- package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +313 -313
- package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -520
- package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -133
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -672
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -86
- package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -208
- package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +134 -130
- package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +673 -673
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -790
- package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +642 -642
- package/vendor/mwi/lib/core/code-engine.d.ts +13 -0
- package/vendor/mwi/lib/core/code-engine.js +18 -1
- package/vendor/mwi/lib/edit.d.ts +1 -1
- package/vendor/mwi/lib/edit.js +1 -1
- package/vendor/mwi/lib/read/service.js +8 -1
- package/vendor/mwi/lib/session/backup.d.ts +72 -0
- package/vendor/mwi/lib/session/backup.js +257 -0
- package/vendor/mwi/lib/session/project.d.ts +4 -1
- package/vendor/mwi/lib/session/project.js +13 -2
- package/vendor/mwi/lib/session/service.d.ts +13 -6
- package/vendor/mwi/lib/session/service.js +127 -36
- package/vendor/mwi/lib/session/workspace.d.ts +10 -0
- package/vendor/mwi/lib/session/workspace.js +53 -3
- package/vendor/mwi/lib/skill.d.ts +2 -2
- package/vendor/mwi/lib/skill.js +2 -2
- package/vendor/mwi/lib/tools/edit.d.ts +3 -3
- package/vendor/mwi/lib/tools/edit.js +14 -15
- package/vendor/mwi/lib/tools/read.js +1 -1
- package/vendor/mwi/lib/tools/session.js +13 -9
- package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix +81 -81
- package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix.design +38 -38
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Alarms/Alarms.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/AryaNewHMIProject_IPC.yaml +67 -67
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Converters/Converters.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/DataStores/DataStores.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Loggers/Loggers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/MQTT/MQTT.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Model/Model.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/NetLogic/NetLogic.yaml +26 -26
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Recipes/Recipes.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Reports/Reports.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Retentivity/Retentivity.yaml +57 -57
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Groups/Groups.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Roles/Roles.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Security.yaml +7 -7
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Users/Users.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/System/System.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Translations/Translations.yaml +16 -16
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Screens/Screens.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/UI.yaml +250 -250
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.code-workspace +9 -9
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.csproj +19 -19
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.references +172 -172
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.sln +21 -21
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +20 -20
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +11 -11
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/UserDefinedModule.xml +26 -26
- package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix +97 -97
- package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix.design +38 -38
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Alarms/Alarms.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/AryaNewHMIProject_OptixPanel.yaml +67 -67
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Converters/Converters.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/DataStores/DataStores.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Loggers/Loggers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/MQTT/MQTT.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Model/Model.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/NetLogic/NetLogic.yaml +26 -26
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Recipes/Recipes.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Reports/Reports.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Retentivity/Retentivity.yaml +57 -57
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Groups/Groups.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Roles/Roles.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Security.yaml +7 -7
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Users/Users.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/System/System.yaml +444 -444
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Translations/Translations.yaml +81 -81
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Screens/Screens.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidget.yaml +163 -163
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidgetComponents/DeviceSettingsWidgetComponents.yaml +1663 -1663
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FTRemoteAccessWidgetComponents/FTRemoteAccessWidgetComponents.yaml +2471 -2471
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FactoryTalkRemoteAccessWidget.yaml +218 -218
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidget.yaml +524 -524
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidgetComponents/NetworkSettingsWidgetComponents.yaml +690 -690
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/SettingsWidgets.yaml +1265 -1265
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/UI.yaml +252 -252
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ApplicationNameLogic.cs +19 -19
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.code-workspace +9 -9
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.csproj +19 -19
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.references +172 -172
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.sln +21 -21
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ConnectionSwitchLogic.cs +74 -74
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DateAndTimeWidgetLogic.cs +420 -420
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DeviceSettingsWidgetLogic.cs +34 -34
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTOptixStudioVersionLogic.cs +47 -47
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTRemoteAccessWidgetSetupLogic.cs +153 -153
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionApprovalLogic.cs +118 -118
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionReceiverLogic.cs +166 -166
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkConfiguratorEditModelsLogic.cs +56 -56
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfaceLogic.cs +138 -138
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelReaderLogic.cs +87 -87
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelToSystemWriterLogic.cs +139 -139
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkSettingsLogic.cs +168 -168
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/FTRemoteAccessWidgetDataObject.cs +106 -106
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +37 -37
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +91 -91
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/RequestAssistanceSwitchLogic.cs +93 -93
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/SystemToNetworkInterfacesEditModelWriterLogic.cs +77 -77
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/UserDefinedModule.xml +111 -111
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/WarningIcon.svg +4 -4
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
|
@@ -1,790 +1,790 @@
|
|
|
1
|
-
"""Structural edits to the project tree: assigning and unassigning program instances.
|
|
2
|
-
|
|
3
|
-
This is the first write path into ``PROJECT.TRE``. It exists on top of the
|
|
4
|
-
round-trip guarantee in :mod:`motionworks_iec_mcp.tree`: the parser reproduces the
|
|
5
|
-
stream byte-for-byte and its record spans tile the source, so an edit can be
|
|
6
|
-
expressed as a splice at known line numbers rather than as a re-serialisation that
|
|
7
|
-
might quietly reorder records.
|
|
8
|
-
|
|
9
|
-
**Task assignment was chosen as the first structural write deliberately.** It adds
|
|
10
|
-
or removes one program-instance child under an existing task and moves a handful
|
|
11
|
-
of counts. It needs no new POU folder, no worksheet records and no library
|
|
12
|
-
updates -- so if the count bookkeeping is wrong, it is wrong in the simplest
|
|
13
|
-
possible case.
|
|
14
|
-
|
|
15
|
-
What an edit has to keep consistent, all established from real trees:
|
|
16
|
-
|
|
17
|
-
* a task node is 10 lines plus 10 per assigned instance, and instances are its
|
|
18
|
-
children;
|
|
19
|
-
* ``params[1]`` is the node's **type/level**: 0 Project, 1 Libraries/POUs, 3
|
|
20
|
-
Configuration, 4 Resource, 5 ``Tasks``, 6 a task, 7 a program instance;
|
|
21
|
-
* ``params[2]`` is the child count and must rise or fall with the instance;
|
|
22
|
-
* every ancestor's ``params[2]`` moves too, and line 1 holds the project total;
|
|
23
|
-
* the node id in ``params[0]`` is unique and the header total equals the number
|
|
24
|
-
of nodes, so a new instance takes the next id and increments the total.
|
|
25
|
-
|
|
26
|
-
Assignments are also recorded in ``NODES.LST`` (project root and resource
|
|
27
|
-
directory where present), matching the proven case study.
|
|
28
|
-
|
|
29
|
-
None of this can be compile-verified in this environment -- the IDE does not load
|
|
30
|
-
projects -- so every edit re-parses its own output and checks the invariants, and
|
|
31
|
-
:mod:`motionworks_iec_mcp.writer` refuses to write while MotionWorks is running.
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
from __future__ import annotations
|
|
35
|
-
|
|
36
|
-
import uuid
|
|
37
|
-
from dataclasses import dataclass, field
|
|
38
|
-
from pathlib import Path
|
|
39
|
-
|
|
40
|
-
from .errors import IdeRunning, MotionWorksError, NotFound, VerificationFailed
|
|
41
|
-
from .tree import TreeDocument, TreeNode, parse_document
|
|
42
|
-
|
|
43
|
-
#: Node levels, read off real trees. ``Project`` is 0, ``Configuration`` 3,
|
|
44
|
-
#: ``Resource`` 4, ``Tasks`` and the sibling containers 4, a task 5, and a program
|
|
45
|
-
#: instance 6. An instance therefore sits exactly one level below its task.
|
|
46
|
-
LEVEL_TASKS_CONTAINER = 4
|
|
47
|
-
LEVEL_TASK = 5
|
|
48
|
-
LEVEL_INSTANCE = 6
|
|
49
|
-
|
|
50
|
-
#: ``params[2]`` is the node's **descendant** count, not its direct child count.
|
|
51
|
-
#: Measured: it equals the true descendant count on 266 of 273 nodes across the
|
|
52
|
-
#: five sample projects. The seven exceptions are exactly the expandable POU and
|
|
53
|
-
#: function-block containers (``Logical POUs``, ``Tasks``, ``RK_FunctionBlocks``),
|
|
54
|
-
#: which under-report by precisely the number of program instances beneath them.
|
|
55
|
-
#:
|
|
56
|
-
#: The practical consequence for an edit is that a *program instance* does not
|
|
57
|
-
#: move the intermediate container counts. Only the owning task and the project
|
|
58
|
-
#: root change. Bumping every ancestor was an earlier mistake here; the proven
|
|
59
|
-
#: case study bumps exactly these two, so that is what this module does.
|
|
60
|
-
COUNT_MOVES_ON_INSTANCE_EDIT = ("task", "root")
|
|
61
|
-
|
|
62
|
-
#: Lines a program-instance record occupies: the marker line through the final state line.
|
|
63
|
-
#: Nine, and the separating blank is NOT included - it sits just past ``end_line``, and
|
|
64
|
-
#: ``plan_assign`` inserts at that blank, so nine lines push it down and keep the separator.
|
|
65
|
-
#:
|
|
66
|
-
#: Verified line for line against a real instance from the tree:
|
|
67
|
-
#: 0 | '19' marker
|
|
68
|
-
#: 1 | '52 6 0 0' params
|
|
69
|
-
#: 2 | 'TopCutterCamSetup 0 0'
|
|
70
|
-
#: 3 | '...eCLR...DEFAULT -1'
|
|
71
|
-
#: 4 | '' blank
|
|
72
|
-
#: 5 | '0 -1 0 0 0 0 0 0 0'
|
|
73
|
-
#: 6 | '' blank
|
|
74
|
-
#: 7 | 'B8D8361F 833D 453B A6 38 9B 57 29 67 79 20'
|
|
75
|
-
#: 8 | '0 0 0 0 0 0 0 0 0 0'
|
|
76
|
-
INSTANCE_LINES = 9
|
|
77
|
-
|
|
78
|
-
#: The resource task cycle field carried by instance records.
|
|
79
|
-
DEFAULT_CYCLE = "CYCLIC"
|
|
80
|
-
|
|
81
|
-
#: Node-list file locations, project root first.
|
|
82
|
-
NODE_LIST_PATHS = (
|
|
83
|
-
"NODES.LST",
|
|
84
|
-
"C/Configuration/R/Resource/NODES.LST",
|
|
85
|
-
"C/Resource/R/Resource/NODES.LST",
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
class TreeWriteRefused(MotionWorksError):
|
|
90
|
-
"""The requested structural edit was refused before writing."""
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def guid_line() -> str:
|
|
94
|
-
"""Format a fresh GUID the way the tree stores one.
|
|
95
|
-
|
|
96
|
-
Reproduces the proven case study: the first three groups are emitted
|
|
97
|
-
little-endian as ``.NET`` does for a GUID's ``ToByteArray``, the rest are
|
|
98
|
-
plain bytes. Matching that layout matters because MotionWorks compares these
|
|
99
|
-
values.
|
|
100
|
-
"""
|
|
101
|
-
raw = uuid.uuid4().bytes_le
|
|
102
|
-
return (
|
|
103
|
-
f"{int.from_bytes(raw[0:4], 'little'):08X} "
|
|
104
|
-
f"{int.from_bytes(raw[4:6], 'little'):04X} "
|
|
105
|
-
f"{int.from_bytes(raw[6:8], 'little'):04X} "
|
|
106
|
-
+ " ".join(f"{byte:02X}" for byte in raw[8:])
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
@dataclass
|
|
111
|
-
class AssignmentPlan:
|
|
112
|
-
"""A planned task assignment or unassignment."""
|
|
113
|
-
|
|
114
|
-
task_name: str
|
|
115
|
-
program_name: str
|
|
116
|
-
assign: bool
|
|
117
|
-
#: The instance block to insert (assign only).
|
|
118
|
-
instance_lines: list[str] = field(default_factory=list)
|
|
119
|
-
#: Line index the instance is inserted at (assign only).
|
|
120
|
-
insert_at: int = 0
|
|
121
|
-
#: ``(span, replacement)`` pairs for the count fields that move.
|
|
122
|
-
count_edits: list[tuple[tuple[int, int], list[str]]] = field(default_factory=list)
|
|
123
|
-
#: Header total before and after.
|
|
124
|
-
total_before: int = 0
|
|
125
|
-
total_after: int = 0
|
|
126
|
-
new_node_id: int = 0
|
|
127
|
-
instance_span: tuple[int, int] = (0, 0)
|
|
128
|
-
notes: list[str] = field(default_factory=list)
|
|
129
|
-
|
|
130
|
-
def summary(self) -> str:
|
|
131
|
-
verb = "assign" if self.assign else "unassign"
|
|
132
|
-
lines = [
|
|
133
|
-
f"{verb.upper()} {self.program_name} "
|
|
134
|
-
f"{'to' if self.assign else 'from'} task {self.task_name}",
|
|
135
|
-
]
|
|
136
|
-
if self.assign:
|
|
137
|
-
lines.append(
|
|
138
|
-
f" new instance at line {self.insert_at} "
|
|
139
|
-
f"({len(self.instance_lines)} lines), node id {self.new_node_id}"
|
|
140
|
-
)
|
|
141
|
-
else:
|
|
142
|
-
lines.append(
|
|
143
|
-
f" removing instances at lines "
|
|
144
|
-
f"{self.instance_span[0]}-{self.instance_span[1]}"
|
|
145
|
-
)
|
|
146
|
-
lines.append(f" node total {self.total_before} -> {self.total_after}")
|
|
147
|
-
lines.append(f" fields updated: {len(self.count_edits)}")
|
|
148
|
-
for note in self.notes:
|
|
149
|
-
lines.append(f" note: {note}")
|
|
150
|
-
return "\n".join(lines)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def _task_nodes(document: TreeDocument) -> list[TreeNode]:
|
|
154
|
-
return [
|
|
155
|
-
node
|
|
156
|
-
for node, _ in document.walk_with_ancestors()
|
|
157
|
-
if node.params[1] == LEVEL_TASK
|
|
158
|
-
]
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def find_task(document: TreeDocument, task_name: str) -> TreeNode:
|
|
162
|
-
"""Locate a task node by name, case-insensitively."""
|
|
163
|
-
matches = [
|
|
164
|
-
node for node in _task_nodes(document)
|
|
165
|
-
if node.name.lower() == task_name.lower()
|
|
166
|
-
]
|
|
167
|
-
if not matches:
|
|
168
|
-
available = ", ".join(sorted(node.name for node in _task_nodes(document)))
|
|
169
|
-
raise NotFound(
|
|
170
|
-
f"no task named {task_name!r} in the project tree. Available: {available}"
|
|
171
|
-
)
|
|
172
|
-
if len(matches) > 1:
|
|
173
|
-
raise TreeWriteRefused(f"task name {task_name!r} is ambiguous")
|
|
174
|
-
return matches[0]
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def instance_of(task: TreeNode, program_name: str) -> TreeNode | None:
|
|
178
|
-
"""The assignment of ``program_name`` under ``task``, if any."""
|
|
179
|
-
for child in task.children:
|
|
180
|
-
if child.name.lower() == program_name.lower():
|
|
181
|
-
return child
|
|
182
|
-
return None
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def _next_node_id(document: TreeDocument) -> int:
|
|
186
|
-
return max((node.node_id for node, _ in document.walk_with_ancestors()), default=0) + 1
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def _count_line_edit(node: TreeNode, new_count: int) -> tuple[tuple[int, int], list[str]]:
|
|
190
|
-
"""Edit a node's params line to carry a new child count."""
|
|
191
|
-
params = list(node.params)
|
|
192
|
-
params[2] = new_count
|
|
193
|
-
return ((node.line, node.line + 1), [" ".join(str(value) for value in params)])
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def _instance_marker(document: TreeDocument) -> str:
|
|
197
|
-
"""The value that precedes a program-instance node's header line.
|
|
198
|
-
|
|
199
|
-
A tree node is a 10-line block whose FIRST line is a small integer, then the params
|
|
200
|
-
line ``id level childcount flags``, then the name, the path and so on. Measured on a
|
|
201
|
-
real project, that first value depends on the node's TYPE and not on its id: all
|
|
202
|
-
seven level-6 program instances carry ``19`` and all five level-5 tasks carry ``20``,
|
|
203
|
-
while their ids run 3..57. It is a constant per node type.
|
|
204
|
-
|
|
205
|
-
The original code wrote ``node_id - 1`` here, which is a value that appears nowhere
|
|
206
|
-
in the tree - every instance says 19. That matters: the IDE rewrites PROJECT.TRE when
|
|
207
|
-
it opens a project, and a tree containing such a block comes back damaged, with the
|
|
208
|
-
header line of the nodes that FOLLOW it losing its level field. Measured, opening a
|
|
209
|
-
project whose tree contained a generated instance block turned three later nodes
|
|
210
|
-
into ``13 0 0 0`` with a file path where the name belongs, and destroyed the
|
|
211
|
-
Global_Variables node - which is why the compiler then reported "No matching global
|
|
212
|
-
variable found" for every POU.
|
|
213
|
-
|
|
214
|
-
Copying the value from an existing instance is better than hard-coding 19: it stays
|
|
215
|
-
correct if the meaning differs in another project, and it needs no instance to exist
|
|
216
|
-
for the common case of a project that already has an assignment.
|
|
217
|
-
"""
|
|
218
|
-
for node, _ in document.walk_with_ancestors():
|
|
219
|
-
if node.params[1] == LEVEL_INSTANCE and node.line > 0:
|
|
220
|
-
value = document.lines[node.line - 1].strip()
|
|
221
|
-
if value.isdigit():
|
|
222
|
-
return value
|
|
223
|
-
return "19"
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def _instance_block(
|
|
227
|
-
program_name: str,
|
|
228
|
-
node_id: int,
|
|
229
|
-
cycle: str,
|
|
230
|
-
controller: str,
|
|
231
|
-
marker: str = "19",
|
|
232
|
-
) -> list[str]:
|
|
233
|
-
"""Build the 9-line program-instance record.
|
|
234
|
-
|
|
235
|
-
Field order mirrors an existing instance exactly: marker, params, name,
|
|
236
|
-
``name\\teCLR\\t<controller>\\t<cycle>\\t-1``, a blank, a state line, a blank,
|
|
237
|
-
a fresh GUID, a trailing state line, and the blank that separates it from the
|
|
238
|
-
next record.
|
|
239
|
-
|
|
240
|
-
NINE, and the trailing blank is NOT part of it - which was worth checking, because
|
|
241
|
-
getting it wrong the other way is easy and looks plausible.
|
|
242
|
-
|
|
243
|
-
Measured against a real instance: the parser's span runs from the MARKER line through the
|
|
244
|
-
final state line, nine lines, and the separating blank sits just past ``end_line``.
|
|
245
|
-
``plan_assign`` inserts at ``task.end_line``, which IS that blank, so inserting nine lines
|
|
246
|
-
pushes the blank down and the separator is preserved. Emitting ten - adding a blank to the
|
|
247
|
-
block - would leave TWO blanks and an over-long record.
|
|
248
|
-
|
|
249
|
-
An earlier attempt added that tenth line on the theory that a missing separator was why
|
|
250
|
-
the IDE rewrote the tree at open. The line count was wrong, and so was the theory: with
|
|
251
|
-
nine lines the block already matches a real instance line for line. The tree damage has
|
|
252
|
-
some other cause, and this was not it.
|
|
253
|
-
|
|
254
|
-
``marker`` is the value an existing instance carries on its first line; see
|
|
255
|
-
:func:`_instance_marker`.
|
|
256
|
-
"""
|
|
257
|
-
return [
|
|
258
|
-
marker,
|
|
259
|
-
f"{node_id} {LEVEL_INSTANCE} 0 0",
|
|
260
|
-
f"{program_name}\t0\t0\t",
|
|
261
|
-
f"{program_name}\teCLR\t{controller}\t{cycle}\t-1\t",
|
|
262
|
-
"",
|
|
263
|
-
"0\t-1\t0\t0\t0\t0\t0\t0\t0",
|
|
264
|
-
"",
|
|
265
|
-
guid_line(),
|
|
266
|
-
"0\t0\t0\t0\t0\t0\t0\t0\t0\t0",
|
|
267
|
-
]
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def task_cycle(document: TreeDocument, task: TreeNode) -> str:
|
|
271
|
-
"""The cycle a task carries, read from its OWN path line.
|
|
272
|
-
|
|
273
|
-
EVERY program instance carries its task's cycle - measured across all eight instances of a
|
|
274
|
-
real project: BG is DEFAULT and its two instances are DEFAULT, FastTsk and MedTsk and
|
|
275
|
-
SlowTsk are CYCLIC and their five instances are CYCLIC, and Start is SYSTEM and its
|
|
276
|
-
instance is SYSTEM. Eight of eight.
|
|
277
|
-
|
|
278
|
-
The path line is ``<name>\teCLR\t<controller>\t<cycle>\t<runtime>\t``, four lines below the
|
|
279
|
-
task's marker. Deriving it matters for the Start task in particular: SYSTEM is not a cycle
|
|
280
|
-
so much as a statement about who owns the task, and writing CYCLIC there would contradict
|
|
281
|
-
the IDE's own record of it.
|
|
282
|
-
|
|
283
|
-
Falls back to DEFAULT_CYCLE when the line cannot be read, so a tree that does not match
|
|
284
|
-
the measured layout still produces something rather than raising.
|
|
285
|
-
"""
|
|
286
|
-
lines = document.lines
|
|
287
|
-
for offset in range(0, 6):
|
|
288
|
-
index = task.line - 1 + offset
|
|
289
|
-
if 0 <= index < len(lines):
|
|
290
|
-
fields = lines[index].split("\t")
|
|
291
|
-
if len(fields) > 3 and fields[1].strip() == "eCLR":
|
|
292
|
-
cycle = fields[3].strip()
|
|
293
|
-
if cycle:
|
|
294
|
-
return cycle
|
|
295
|
-
return DEFAULT_CYCLE
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def plan_assign(
|
|
299
|
-
document: TreeDocument,
|
|
300
|
-
task_name: str,
|
|
301
|
-
program_name: str,
|
|
302
|
-
*,
|
|
303
|
-
cycle: str | None = None,
|
|
304
|
-
controller: str = "MP2600iec",
|
|
305
|
-
) -> AssignmentPlan:
|
|
306
|
-
"""Plan assigning ``program_name`` to ``task_name``.
|
|
307
|
-
|
|
308
|
-
``cycle`` defaults to the TASK'S OWN cycle rather than a fixed string: every real instance
|
|
309
|
-
carries its task's, and the Start task's is SYSTEM.
|
|
310
|
-
"""
|
|
311
|
-
task = find_task(document, task_name)
|
|
312
|
-
if cycle is None:
|
|
313
|
-
cycle = task_cycle(document, task)
|
|
314
|
-
if instance_of(task, program_name) is not None:
|
|
315
|
-
raise TreeWriteRefused(
|
|
316
|
-
f"{program_name!r} is already assigned to task {task.name!r}"
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
total_before = int(document.lines[1])
|
|
320
|
-
new_node_id = _next_node_id(document)
|
|
321
|
-
|
|
322
|
-
plan = AssignmentPlan(
|
|
323
|
-
task_name=task.name,
|
|
324
|
-
program_name=program_name,
|
|
325
|
-
assign=True,
|
|
326
|
-
instance_lines=_instance_block(
|
|
327
|
-
program_name, new_node_id, cycle, controller,
|
|
328
|
-
marker=_instance_marker(document),
|
|
329
|
-
),
|
|
330
|
-
insert_at=task.end_line,
|
|
331
|
-
total_before=total_before,
|
|
332
|
-
total_after=total_before + 1,
|
|
333
|
-
new_node_id=new_node_id,
|
|
334
|
-
)
|
|
335
|
-
|
|
336
|
-
# Only the owning task and the project root move; see
|
|
337
|
-
# COUNT_MOVES_ON_INSTANCE_EDIT. The task's field counts its instances and the
|
|
338
|
-
# root's counts the whole project. Intermediate containers are deliberately
|
|
339
|
-
# left alone: because their fields under-report program instances, they do not
|
|
340
|
-
# track this edit, and the proven case study changes exactly these two.
|
|
341
|
-
plan.count_edits.append(_count_line_edit(task, task.params[2] + 1))
|
|
342
|
-
ancestors = next(
|
|
343
|
-
chain for node, chain in document.walk_with_ancestors() if node is task
|
|
344
|
-
)
|
|
345
|
-
for ancestor in ancestors:
|
|
346
|
-
if ancestor is document.roots[0]:
|
|
347
|
-
plan.count_edits.append(
|
|
348
|
-
_count_line_edit(ancestor, ancestor.params[2] + 1)
|
|
349
|
-
)
|
|
350
|
-
|
|
351
|
-
plan.notes.append(
|
|
352
|
-
f"cycle {cycle} on {controller}, taken from the task's own assignment style"
|
|
353
|
-
)
|
|
354
|
-
return plan
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def plan_unassign(
|
|
358
|
-
document: TreeDocument, task_name: str, program_name: str
|
|
359
|
-
) -> AssignmentPlan:
|
|
360
|
-
"""Plan removing ``program_name`` from ``task_name``."""
|
|
361
|
-
task = find_task(document, task_name)
|
|
362
|
-
instance = instance_of(task, program_name)
|
|
363
|
-
if instance is None:
|
|
364
|
-
raise NotFound(
|
|
365
|
-
f"{program_name!r} is not assigned to task {task.name!r}"
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
total_before = int(document.lines[1])
|
|
369
|
-
plan = AssignmentPlan(
|
|
370
|
-
task_name=task.name,
|
|
371
|
-
program_name=instance.name,
|
|
372
|
-
assign=False,
|
|
373
|
-
total_before=total_before,
|
|
374
|
-
total_after=max(0, total_before - 1),
|
|
375
|
-
instance_span=instance.span,
|
|
376
|
-
)
|
|
377
|
-
|
|
378
|
-
# Mirror plan_assign: the task and the root move, nothing in between.
|
|
379
|
-
plan.count_edits.append(_count_line_edit(task, max(0, task.params[2] - 1)))
|
|
380
|
-
ancestors = next(
|
|
381
|
-
chain for node, chain in document.walk_with_ancestors() if node is task
|
|
382
|
-
)
|
|
383
|
-
for ancestor in ancestors:
|
|
384
|
-
if ancestor is document.roots[0]:
|
|
385
|
-
plan.count_edits.append(
|
|
386
|
-
_count_line_edit(ancestor, max(0, ancestor.params[2] - 1))
|
|
387
|
-
)
|
|
388
|
-
return plan
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
def render(document: TreeDocument, plan: AssignmentPlan) -> str:
|
|
392
|
-
"""Produce the edited tree text for a plan, without writing anything."""
|
|
393
|
-
edits: list[tuple[int, int, list[str]]] = []
|
|
394
|
-
|
|
395
|
-
if plan.assign:
|
|
396
|
-
edits.append((plan.insert_at, plan.insert_at, plan.instance_lines))
|
|
397
|
-
else:
|
|
398
|
-
start, end = plan.instance_span
|
|
399
|
-
edits.append((start, end, []))
|
|
400
|
-
|
|
401
|
-
for span, replacement in plan.count_edits:
|
|
402
|
-
edits.append((span[0], span[1], replacement))
|
|
403
|
-
|
|
404
|
-
# The header total moves with the node count.
|
|
405
|
-
edits.append((1, 2, [str(plan.total_after)]))
|
|
406
|
-
return document.splice(edits)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
def default_backup_dir(project_root: Path) -> Path:
|
|
410
|
-
"""Where tree-edit backups go.
|
|
411
|
-
|
|
412
|
-
Deliberately outside the project tree, so a backup can never be mistaken for
|
|
413
|
-
project content. Honours ``MOTIONWORKS_MCP_BACKUP_DIR`` like the snapshot
|
|
414
|
-
tooling, and stamps the time so successive edits do not overwrite each other.
|
|
415
|
-
"""
|
|
416
|
-
import os
|
|
417
|
-
from datetime import datetime
|
|
418
|
-
|
|
419
|
-
base = os.environ.get("MOTIONWORKS_MCP_BACKUP_DIR")
|
|
420
|
-
root = Path(base) if base else Path(project_root).parent / ".mw_backups"
|
|
421
|
-
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
422
|
-
return root / f"tree-{stamp}"
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
def node_list_line(program_name: str, controller: str = "MP2600iec") -> str:
|
|
426
|
-
"""The ``NODES.LST`` line recording one program instance.
|
|
427
|
-
|
|
428
|
-
Field order matches real files, e.g.
|
|
429
|
-
``PROGRAM\\t6\\tServoHoming\\tServoHoming\\teCLR\\tMP2600iec`` -- a ``PROGRAM``
|
|
430
|
-
record carries its own level (6), the same value the tree node uses.
|
|
431
|
-
"""
|
|
432
|
-
return (
|
|
433
|
-
f"PROGRAM\t{LEVEL_INSTANCE}\t{program_name}\t{program_name}"
|
|
434
|
-
f"\teCLR\t{controller}"
|
|
435
|
-
)
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
def node_list_files(project_root: Path) -> list[Path]:
|
|
439
|
-
"""Existing node-list files for a project, in canonical order."""
|
|
440
|
-
root = Path(project_root)
|
|
441
|
-
return [root / relative for relative in NODE_LIST_PATHS if (root / relative).is_file()]
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
def plan_node_list_edits(
|
|
445
|
-
project_root: Path, plan: AssignmentPlan
|
|
446
|
-
) -> list[tuple[Path, bytes, bytes]]:
|
|
447
|
-
"""Plan ``NODES.LST`` edits as ``(path, before, after)`` triples.
|
|
448
|
-
|
|
449
|
-
The tree alone is not enough: MotionWorks also records each instance in the
|
|
450
|
-
node lists, and the proven case study updates both. The line is inserted at
|
|
451
|
-
the end of the target task's block, keeping the file's existing line ending.
|
|
452
|
-
"""
|
|
453
|
-
wanted = f"PROGRAM\t{LEVEL_INSTANCE}\t{plan.program_name}"
|
|
454
|
-
results: list[tuple[Path, bytes, bytes]] = []
|
|
455
|
-
|
|
456
|
-
for path in node_list_files(project_root):
|
|
457
|
-
raw = path.read_bytes()
|
|
458
|
-
text = raw.decode("latin1")
|
|
459
|
-
ending = "\r\n" if "\r\n" in text else "\n"
|
|
460
|
-
lines = text.replace("\r\n", "\n").split("\n")
|
|
461
|
-
if text.endswith("\n"):
|
|
462
|
-
lines = lines[:-1]
|
|
463
|
-
|
|
464
|
-
# Locate the target task's block: its TASK line plus the PROGRAM lines
|
|
465
|
-
# that follow it, ending at the next non-PROGRAM line.
|
|
466
|
-
block_start = None
|
|
467
|
-
block_end = None
|
|
468
|
-
for position, line in enumerate(lines):
|
|
469
|
-
fields = line.split("\t")
|
|
470
|
-
if (
|
|
471
|
-
len(fields) >= 3
|
|
472
|
-
and fields[0] == "TASK"
|
|
473
|
-
and fields[2] == plan.task_name
|
|
474
|
-
):
|
|
475
|
-
block_start = position
|
|
476
|
-
block_end = position + 1
|
|
477
|
-
continue
|
|
478
|
-
if block_start is not None:
|
|
479
|
-
if fields[0] == "PROGRAM":
|
|
480
|
-
block_end = position + 1
|
|
481
|
-
elif line.strip():
|
|
482
|
-
break
|
|
483
|
-
|
|
484
|
-
if block_start is None:
|
|
485
|
-
if plan.assign:
|
|
486
|
-
raise NotFound(f"task {plan.task_name!r} not found in {path.name}")
|
|
487
|
-
# Nothing to remove if the task is absent from this list.
|
|
488
|
-
continue
|
|
489
|
-
|
|
490
|
-
# The duplicate check must be scoped to the task's block. A program
|
|
491
|
-
# instance belongs to a task, so the same POU can legitimately run in two
|
|
492
|
-
# different tasks and appears once under each -- refusing on a name that
|
|
493
|
-
# exists anywhere in the file rejects a valid operation.
|
|
494
|
-
already_here = any(
|
|
495
|
-
lines[position].startswith(wanted)
|
|
496
|
-
for position in range(block_start, block_end)
|
|
497
|
-
)
|
|
498
|
-
if plan.assign:
|
|
499
|
-
if already_here:
|
|
500
|
-
raise TreeWriteRefused(
|
|
501
|
-
f"{plan.program_name!r} is already assigned to "
|
|
502
|
-
f"{plan.task_name!r} in {path.name}"
|
|
503
|
-
)
|
|
504
|
-
lines.insert(block_end, node_list_line(plan.program_name))
|
|
505
|
-
else:
|
|
506
|
-
if not already_here:
|
|
507
|
-
continue
|
|
508
|
-
del lines[block_end - 1]
|
|
509
|
-
|
|
510
|
-
after = (ending.join(lines) + (ending if text.endswith("\n") else "")).encode(
|
|
511
|
-
"latin1"
|
|
512
|
-
)
|
|
513
|
-
results.append((path, raw, after))
|
|
514
|
-
return results
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
def backup_and_write(
|
|
518
|
-
writes: list[tuple[Path, bytes, bytes]],
|
|
519
|
-
backup_dir: Path,
|
|
520
|
-
project_root: Path | None = None,
|
|
521
|
-
) -> list[str]:
|
|
522
|
-
"""Back up each file, then write it. Returns the backup paths.
|
|
523
|
-
|
|
524
|
-
All backups are taken before any write, so a failure part-way through leaves
|
|
525
|
-
every original recoverable.
|
|
526
|
-
|
|
527
|
-
Backups keep their path relative to the project root. That is not cosmetic:
|
|
528
|
-
a project has two ``NODES.LST`` files at different paths, and backing up by
|
|
529
|
-
basename alone makes the second overwrite the first, so one original is lost.
|
|
530
|
-
"""
|
|
531
|
-
from shutil import copy2 # noqa: F401 - kept for callers expecting parity
|
|
532
|
-
|
|
533
|
-
backup_root = Path(backup_dir)
|
|
534
|
-
root = Path(project_root) if project_root else None
|
|
535
|
-
created: list[str] = []
|
|
536
|
-
|
|
537
|
-
for path, before, _ in writes:
|
|
538
|
-
if root is not None:
|
|
539
|
-
try:
|
|
540
|
-
relative = path.resolve().relative_to(root.resolve())
|
|
541
|
-
except ValueError:
|
|
542
|
-
relative = Path(path.name)
|
|
543
|
-
else:
|
|
544
|
-
relative = Path(path.name)
|
|
545
|
-
target = backup_root / relative
|
|
546
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
547
|
-
target.write_bytes(before)
|
|
548
|
-
created.append(str(target))
|
|
549
|
-
|
|
550
|
-
for path, _, after in writes:
|
|
551
|
-
path.write_bytes(after)
|
|
552
|
-
|
|
553
|
-
return created
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
def apply_assignment(
|
|
557
|
-
src_path: Path,
|
|
558
|
-
project_root: Path,
|
|
559
|
-
plan: AssignmentPlan,
|
|
560
|
-
*,
|
|
561
|
-
backup_dir: Path,
|
|
562
|
-
) -> dict[str, object]:
|
|
563
|
-
"""Render, verify, back up and write a task assignment or unassignment.
|
|
564
|
-
|
|
565
|
-
The tree is rendered and re-parsed *before* anything is written, so a plan
|
|
566
|
-
that would corrupt the tree is refused rather than applied and rolled back.
|
|
567
|
-
|
|
568
|
-
``PROJECT.TRE`` lives inside the CFB container, so the edited text goes back
|
|
569
|
-
through :class:`CompoundFile` -- reading or writing the file as raw text would
|
|
570
|
-
corrupt the container.
|
|
571
|
-
"""
|
|
572
|
-
_refuse_tree_assignment(
|
|
573
|
-
'unassign' if not getattr(plan, 'assign', True) else 'assign'
|
|
574
|
-
)
|
|
575
|
-
|
|
576
|
-
def _refuse_tree_assignment(action: str = 'assign') -> None:
|
|
577
|
-
"""Refuse writing a program instance into PROJECT.TRE.
|
|
578
|
-
|
|
579
|
-
Isolated, which is what settles it. Assign writes two things - an instance node in the
|
|
580
|
-
tree and a line in NODES.LST - and earlier work treated the pair as one operation. Run
|
|
581
|
-
separately, on freshly staged copies:
|
|
582
|
-
|
|
583
|
-
tree edit only tree 0 -> 3 malformed Global_Variables lost compiled=false
|
|
584
|
-
NODES.LST only tree 0 -> 0 malformed intact compiled=true
|
|
585
|
-
both tree 0 -> 3 malformed Global_Variables lost compiled=false
|
|
586
|
-
|
|
587
|
-
So the tree edit is the damage, on its own. The IDE rewrites PROJECT.TRE at OPEN and
|
|
588
|
-
loses a line from each of three later blocks - Start, Global_Variables and
|
|
589
|
-
IO_Configuration - which is what makes the build report 125 "No matching global
|
|
590
|
-
variable found" errors: the Globals node is gone, so the compiler has no global table.
|
|
591
|
-
The generated node itself was verified correct first: 581 lines from 572, zero malformed
|
|
592
|
-
nodes, Start intact. MotionWorks rejects it anyway.
|
|
593
|
-
|
|
594
|
-
NODES.LST alone does no harm but does not persist - the IDE discards the assignment, so
|
|
595
|
-
the tool would report success for an effect that evaporates. A tool whose success is not
|
|
596
|
-
real is worse than one that declines, so this refuses.
|
|
597
|
-
|
|
598
|
-
Note the pattern, because it is the same one twice: a .VGR record makes the IDE destroy
|
|
599
|
-
the POU (.VB 1132 bytes -> 0, grid 1665 -> 79 MB) and a tree node makes it destroy three
|
|
600
|
-
tree nodes. Both are this plugin attempting to write a MotionWorks binary structure from
|
|
601
|
-
a reconstruction, and both are rejected silently.
|
|
602
|
-
|
|
603
|
-
What works instead: add the program to the task in the MotionWorks Project Tree, which
|
|
604
|
-
is a right-click and a pick, and then use mw_code_tasks to confirm it landed.
|
|
605
|
-
"""
|
|
606
|
-
verb = "unassign" if action == "unassign" else "assign"
|
|
607
|
-
raise TreeWriteRefused(
|
|
608
|
-
f"refusing to {verb} a program from the files.\n"
|
|
609
|
-
"\n"
|
|
610
|
-
"WHAT THE USER SHOULD DO INSTEAD - it takes a moment:\n"
|
|
611
|
-
" 1. In the MotionWorks Project Tree, find the task.\n"
|
|
612
|
-
f" 2. Right-click it and {'remove the program' if action == 'unassign' else 'add the program'}.\n"
|
|
613
|
-
" 3. Call mw_code_tasks to confirm - it reads the assignments from the tree, so a\n"
|
|
614
|
-
" successful assignment appears there.\n"
|
|
615
|
-
" 4. If the user is unsure WHERE to click, mw_ide_screenshot captures the IDE\n"
|
|
616
|
-
" and the Project Tree is visible in it, with the tasks and their assigned\n"
|
|
617
|
-
" programs listed under Physical Hardware > Configuration > Resource >\n"
|
|
618
|
-
" Tasks. Showing it is often faster than describing it.\n"
|
|
619
|
-
"\n"
|
|
620
|
-
"WHY THIS IS REFUSED. Writing the instance node into PROJECT.TRE makes the IDE\n"
|
|
621
|
-
"rewrite the tree when it opens the project. Measured: the Start, Global_Variables\n"
|
|
622
|
-
"and IO_Configuration nodes each lose a line, and the build then reports 125\n"
|
|
623
|
-
"'No matching global variable found' errors because the Globals node is gone and the\n"
|
|
624
|
-
"compiler has no global table. Writing only NODES.LST avoids the damage but the IDE\n"
|
|
625
|
-
"discards the assignment, so the tool would report success for an effect that does\n"
|
|
626
|
-
"not persist - worse than declining.\n"
|
|
627
|
-
"\n"
|
|
628
|
-
"WHAT WAS RULED OUT, each by test, so it is not worth retrying: the node's content\n"
|
|
629
|
-
"(matches a real instance field for field), its length (9 and 10 lines), its\n"
|
|
630
|
-
"position (before and after the task's existing children), the program's compiled\n"
|
|
631
|
-
"state (a compiled POU fails exactly like a fresh one), and the cycle field (fixed,\n"
|
|
632
|
-
"damage unchanged). The IDE keeps the node's marker and GUID and rebuilds its layout\n"
|
|
633
|
-
"around them, so it parses the node and then cannot read the fields.\n"
|
|
634
|
-
"\n"
|
|
635
|
-
"THE MISSING ARTIFACT: one program instance the IDE created itself. Add a program to\n"
|
|
636
|
-
"a task in the Project Tree and the tree will contain one; comparing it against the\n"
|
|
637
|
-
"generated node answers the remaining question in one look."
|
|
638
|
-
)
|
|
639
|
-
from .cfb import CompoundFile
|
|
640
|
-
from .ide import ensure_ide_closed
|
|
641
|
-
|
|
642
|
-
from .staging import assert_proven
|
|
643
|
-
assert_proven(project_root)
|
|
644
|
-
ensure_ide_closed()
|
|
645
|
-
|
|
646
|
-
original = src_path.read_bytes()
|
|
647
|
-
|
|
648
|
-
cfb = CompoundFile(src_path)
|
|
649
|
-
text = cfb.read_stream("PROJECT.TRE").decode("latin1")
|
|
650
|
-
document = parse_document(text)
|
|
651
|
-
edited = render(document, plan)
|
|
652
|
-
problems = verify_tree_text(text, edited, plan)
|
|
653
|
-
if problems:
|
|
654
|
-
raise VerificationFailed(
|
|
655
|
-
"refusing to write: the edited tree failed verification:\n "
|
|
656
|
-
+ "\n ".join(problems)
|
|
657
|
-
)
|
|
658
|
-
|
|
659
|
-
# Build the new container in memory by pointing replace_streams at a scratch
|
|
660
|
-
# file, so nothing is written to the project until every backup exists.
|
|
661
|
-
scratch = Path(backup_dir) / "_pending_src.st1"
|
|
662
|
-
scratch.parent.mkdir(parents=True, exist_ok=True)
|
|
663
|
-
cfb.replace_streams({"PROJECT.TRE": edited.encode("latin1")}, destination=scratch)
|
|
664
|
-
edited_bytes = scratch.read_bytes()
|
|
665
|
-
scratch.unlink()
|
|
666
|
-
|
|
667
|
-
writes: list[tuple[Path, bytes, bytes]] = [(src_path, original, edited_bytes)]
|
|
668
|
-
writes.extend(plan_node_list_edits(Path(project_root), plan))
|
|
669
|
-
|
|
670
|
-
backups = backup_and_write(writes, backup_dir, project_root=Path(project_root))
|
|
671
|
-
|
|
672
|
-
# Re-read from disk and verify, so success reflects the bytes actually stored.
|
|
673
|
-
stored = CompoundFile(src_path).read_stream("PROJECT.TRE").decode("latin1")
|
|
674
|
-
problems = verify_tree_text(text, stored, plan)
|
|
675
|
-
if problems:
|
|
676
|
-
raise VerificationFailed(
|
|
677
|
-
"written tree failed read-back verification:\n " + "\n ".join(problems)
|
|
678
|
-
)
|
|
679
|
-
|
|
680
|
-
return {
|
|
681
|
-
"task": plan.task_name,
|
|
682
|
-
"program": plan.program_name,
|
|
683
|
-
"assigned": plan.assign,
|
|
684
|
-
"node_total": plan.total_after,
|
|
685
|
-
"files": [str(path) for path, _, _ in writes],
|
|
686
|
-
"backups": backups,
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
def verify_tree_text(before: str, after: str, plan: AssignmentPlan) -> list[str]:
|
|
692
|
-
"""Re-parse edited text and check the structural invariants.
|
|
693
|
-
|
|
694
|
-
This is the substitute for a compiler in an environment where the IDE will not
|
|
695
|
-
load a project: it cannot prove MotionWorks accepts the edit, but it does prove
|
|
696
|
-
the edit is internally consistent and that nothing else moved.
|
|
697
|
-
"""
|
|
698
|
-
problems: list[str] = []
|
|
699
|
-
|
|
700
|
-
before_doc = parse_document(before)
|
|
701
|
-
try:
|
|
702
|
-
after_doc = parse_document(after)
|
|
703
|
-
except Exception as exc: # noqa: BLE001
|
|
704
|
-
return [f"edited tree does not parse: {exc}"]
|
|
705
|
-
|
|
706
|
-
after_lines = after_doc.lines
|
|
707
|
-
if int(after_lines[1]) != plan.total_after:
|
|
708
|
-
problems.append(
|
|
709
|
-
f"header total is {after_lines[1]!r}, expected {plan.total_after}"
|
|
710
|
-
)
|
|
711
|
-
|
|
712
|
-
task = find_task(after_doc, plan.task_name)
|
|
713
|
-
if plan.assign:
|
|
714
|
-
if instance_of(task, plan.program_name) is None:
|
|
715
|
-
problems.append(
|
|
716
|
-
f"{plan.program_name!r} is not present under {task.name!r} after edit"
|
|
717
|
-
)
|
|
718
|
-
expected_children = len(task.children)
|
|
719
|
-
if task.params[2] != expected_children:
|
|
720
|
-
problems.append(
|
|
721
|
-
f"task child count is {task.params[2]}, parsed children "
|
|
722
|
-
f"{expected_children}"
|
|
723
|
-
)
|
|
724
|
-
else:
|
|
725
|
-
if instance_of(task, plan.program_name) is not None:
|
|
726
|
-
problems.append(
|
|
727
|
-
f"{plan.program_name!r} is still present under {task.name!r}"
|
|
728
|
-
)
|
|
729
|
-
# Not "the name is absent everywhere": the same POU may legitimately run in
|
|
730
|
-
# another task, and may have done so before this edit. The invariant is
|
|
731
|
-
# that only the target task lost it, so compare which tasks held it before
|
|
732
|
-
# and after.
|
|
733
|
-
def holders(doc) -> set[str]:
|
|
734
|
-
return {
|
|
735
|
-
node.name
|
|
736
|
-
for node, _ in doc.walk_with_ancestors()
|
|
737
|
-
if node.params[1] == LEVEL_TASK
|
|
738
|
-
and instance_of(node, plan.program_name) is not None
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
before_holders = holders(before_doc)
|
|
742
|
-
after_holders = holders(after_doc)
|
|
743
|
-
lost_elsewhere = (before_holders - {task.name}) - after_holders
|
|
744
|
-
if lost_elsewhere:
|
|
745
|
-
problems.append(
|
|
746
|
-
f"removing {plan.program_name!r} from {task.name!r} also removed it "
|
|
747
|
-
f"from {sorted(lost_elsewhere)}"
|
|
748
|
-
)
|
|
749
|
-
gained = after_holders - before_holders - {task.name}
|
|
750
|
-
if gained:
|
|
751
|
-
problems.append(
|
|
752
|
-
f"the edit unexpectedly added {plan.program_name!r} to "
|
|
753
|
-
f"{sorted(gained)}"
|
|
754
|
-
)
|
|
755
|
-
|
|
756
|
-
# Every ancestor's child count must match its parsed children.
|
|
757
|
-
for node, _ in after_doc.walk_with_ancestors():
|
|
758
|
-
# Tasks-style containers legitimately declare more children than the
|
|
759
|
-
# parser reads (empty program slots), so only check nodes whose count the
|
|
760
|
-
# plan actually edited.
|
|
761
|
-
pass
|
|
762
|
-
|
|
763
|
-
# Nothing outside the edited spans may differ.
|
|
764
|
-
before_lines = before_doc.lines
|
|
765
|
-
expected_delta = 1 if plan.assign else -1
|
|
766
|
-
delta = len(after_lines) - len(before_lines)
|
|
767
|
-
if delta != expected_delta * INSTANCE_LINES:
|
|
768
|
-
problems.append(
|
|
769
|
-
f"line count changed by {delta}, expected "
|
|
770
|
-
f"{expected_delta * INSTANCE_LINES}"
|
|
771
|
-
)
|
|
772
|
-
|
|
773
|
-
# Count lines that must have moved.
|
|
774
|
-
edited_lines = {start for (start, _), _ in plan.count_edits} | {1}
|
|
775
|
-
for index in range(3, min(len(before_lines), len(after_lines))):
|
|
776
|
-
if index not in edited_lines and index < len(before_lines) and index < len(after_lines):
|
|
777
|
-
# Compare only indices that were not shifted by the insertion.
|
|
778
|
-
if plan.assign and index >= plan.insert_at:
|
|
779
|
-
continue
|
|
780
|
-
if not plan.assign and index >= plan.instance_span[0]:
|
|
781
|
-
continue
|
|
782
|
-
if before_lines[index] != after_lines[index]:
|
|
783
|
-
problems.append(
|
|
784
|
-
f"line {index} changed unexpectedly: "
|
|
785
|
-
f"{before_lines[index]!r} -> {after_lines[index]!r}"
|
|
786
|
-
)
|
|
787
|
-
if len(problems) > 5:
|
|
788
|
-
return problems
|
|
789
|
-
|
|
790
|
-
return problems
|
|
1
|
+
"""Structural edits to the project tree: assigning and unassigning program instances.
|
|
2
|
+
|
|
3
|
+
This is the first write path into ``PROJECT.TRE``. It exists on top of the
|
|
4
|
+
round-trip guarantee in :mod:`motionworks_iec_mcp.tree`: the parser reproduces the
|
|
5
|
+
stream byte-for-byte and its record spans tile the source, so an edit can be
|
|
6
|
+
expressed as a splice at known line numbers rather than as a re-serialisation that
|
|
7
|
+
might quietly reorder records.
|
|
8
|
+
|
|
9
|
+
**Task assignment was chosen as the first structural write deliberately.** It adds
|
|
10
|
+
or removes one program-instance child under an existing task and moves a handful
|
|
11
|
+
of counts. It needs no new POU folder, no worksheet records and no library
|
|
12
|
+
updates -- so if the count bookkeeping is wrong, it is wrong in the simplest
|
|
13
|
+
possible case.
|
|
14
|
+
|
|
15
|
+
What an edit has to keep consistent, all established from real trees:
|
|
16
|
+
|
|
17
|
+
* a task node is 10 lines plus 10 per assigned instance, and instances are its
|
|
18
|
+
children;
|
|
19
|
+
* ``params[1]`` is the node's **type/level**: 0 Project, 1 Libraries/POUs, 3
|
|
20
|
+
Configuration, 4 Resource, 5 ``Tasks``, 6 a task, 7 a program instance;
|
|
21
|
+
* ``params[2]`` is the child count and must rise or fall with the instance;
|
|
22
|
+
* every ancestor's ``params[2]`` moves too, and line 1 holds the project total;
|
|
23
|
+
* the node id in ``params[0]`` is unique and the header total equals the number
|
|
24
|
+
of nodes, so a new instance takes the next id and increments the total.
|
|
25
|
+
|
|
26
|
+
Assignments are also recorded in ``NODES.LST`` (project root and resource
|
|
27
|
+
directory where present), matching the proven case study.
|
|
28
|
+
|
|
29
|
+
None of this can be compile-verified in this environment -- the IDE does not load
|
|
30
|
+
projects -- so every edit re-parses its own output and checks the invariants, and
|
|
31
|
+
:mod:`motionworks_iec_mcp.writer` refuses to write while MotionWorks is running.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import uuid
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from .errors import IdeRunning, MotionWorksError, NotFound, VerificationFailed
|
|
41
|
+
from .tree import TreeDocument, TreeNode, parse_document
|
|
42
|
+
|
|
43
|
+
#: Node levels, read off real trees. ``Project`` is 0, ``Configuration`` 3,
|
|
44
|
+
#: ``Resource`` 4, ``Tasks`` and the sibling containers 4, a task 5, and a program
|
|
45
|
+
#: instance 6. An instance therefore sits exactly one level below its task.
|
|
46
|
+
LEVEL_TASKS_CONTAINER = 4
|
|
47
|
+
LEVEL_TASK = 5
|
|
48
|
+
LEVEL_INSTANCE = 6
|
|
49
|
+
|
|
50
|
+
#: ``params[2]`` is the node's **descendant** count, not its direct child count.
|
|
51
|
+
#: Measured: it equals the true descendant count on 266 of 273 nodes across the
|
|
52
|
+
#: five sample projects. The seven exceptions are exactly the expandable POU and
|
|
53
|
+
#: function-block containers (``Logical POUs``, ``Tasks``, ``RK_FunctionBlocks``),
|
|
54
|
+
#: which under-report by precisely the number of program instances beneath them.
|
|
55
|
+
#:
|
|
56
|
+
#: The practical consequence for an edit is that a *program instance* does not
|
|
57
|
+
#: move the intermediate container counts. Only the owning task and the project
|
|
58
|
+
#: root change. Bumping every ancestor was an earlier mistake here; the proven
|
|
59
|
+
#: case study bumps exactly these two, so that is what this module does.
|
|
60
|
+
COUNT_MOVES_ON_INSTANCE_EDIT = ("task", "root")
|
|
61
|
+
|
|
62
|
+
#: Lines a program-instance record occupies: the marker line through the final state line.
|
|
63
|
+
#: Nine, and the separating blank is NOT included - it sits just past ``end_line``, and
|
|
64
|
+
#: ``plan_assign`` inserts at that blank, so nine lines push it down and keep the separator.
|
|
65
|
+
#:
|
|
66
|
+
#: Verified line for line against a real instance from the tree:
|
|
67
|
+
#: 0 | '19' marker
|
|
68
|
+
#: 1 | '52 6 0 0' params
|
|
69
|
+
#: 2 | 'TopCutterCamSetup 0 0'
|
|
70
|
+
#: 3 | '...eCLR...DEFAULT -1'
|
|
71
|
+
#: 4 | '' blank
|
|
72
|
+
#: 5 | '0 -1 0 0 0 0 0 0 0'
|
|
73
|
+
#: 6 | '' blank
|
|
74
|
+
#: 7 | 'B8D8361F 833D 453B A6 38 9B 57 29 67 79 20'
|
|
75
|
+
#: 8 | '0 0 0 0 0 0 0 0 0 0'
|
|
76
|
+
INSTANCE_LINES = 9
|
|
77
|
+
|
|
78
|
+
#: The resource task cycle field carried by instance records.
|
|
79
|
+
DEFAULT_CYCLE = "CYCLIC"
|
|
80
|
+
|
|
81
|
+
#: Node-list file locations, project root first.
|
|
82
|
+
NODE_LIST_PATHS = (
|
|
83
|
+
"NODES.LST",
|
|
84
|
+
"C/Configuration/R/Resource/NODES.LST",
|
|
85
|
+
"C/Resource/R/Resource/NODES.LST",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TreeWriteRefused(MotionWorksError):
|
|
90
|
+
"""The requested structural edit was refused before writing."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def guid_line() -> str:
|
|
94
|
+
"""Format a fresh GUID the way the tree stores one.
|
|
95
|
+
|
|
96
|
+
Reproduces the proven case study: the first three groups are emitted
|
|
97
|
+
little-endian as ``.NET`` does for a GUID's ``ToByteArray``, the rest are
|
|
98
|
+
plain bytes. Matching that layout matters because MotionWorks compares these
|
|
99
|
+
values.
|
|
100
|
+
"""
|
|
101
|
+
raw = uuid.uuid4().bytes_le
|
|
102
|
+
return (
|
|
103
|
+
f"{int.from_bytes(raw[0:4], 'little'):08X} "
|
|
104
|
+
f"{int.from_bytes(raw[4:6], 'little'):04X} "
|
|
105
|
+
f"{int.from_bytes(raw[6:8], 'little'):04X} "
|
|
106
|
+
+ " ".join(f"{byte:02X}" for byte in raw[8:])
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class AssignmentPlan:
|
|
112
|
+
"""A planned task assignment or unassignment."""
|
|
113
|
+
|
|
114
|
+
task_name: str
|
|
115
|
+
program_name: str
|
|
116
|
+
assign: bool
|
|
117
|
+
#: The instance block to insert (assign only).
|
|
118
|
+
instance_lines: list[str] = field(default_factory=list)
|
|
119
|
+
#: Line index the instance is inserted at (assign only).
|
|
120
|
+
insert_at: int = 0
|
|
121
|
+
#: ``(span, replacement)`` pairs for the count fields that move.
|
|
122
|
+
count_edits: list[tuple[tuple[int, int], list[str]]] = field(default_factory=list)
|
|
123
|
+
#: Header total before and after.
|
|
124
|
+
total_before: int = 0
|
|
125
|
+
total_after: int = 0
|
|
126
|
+
new_node_id: int = 0
|
|
127
|
+
instance_span: tuple[int, int] = (0, 0)
|
|
128
|
+
notes: list[str] = field(default_factory=list)
|
|
129
|
+
|
|
130
|
+
def summary(self) -> str:
|
|
131
|
+
verb = "assign" if self.assign else "unassign"
|
|
132
|
+
lines = [
|
|
133
|
+
f"{verb.upper()} {self.program_name} "
|
|
134
|
+
f"{'to' if self.assign else 'from'} task {self.task_name}",
|
|
135
|
+
]
|
|
136
|
+
if self.assign:
|
|
137
|
+
lines.append(
|
|
138
|
+
f" new instance at line {self.insert_at} "
|
|
139
|
+
f"({len(self.instance_lines)} lines), node id {self.new_node_id}"
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
lines.append(
|
|
143
|
+
f" removing instances at lines "
|
|
144
|
+
f"{self.instance_span[0]}-{self.instance_span[1]}"
|
|
145
|
+
)
|
|
146
|
+
lines.append(f" node total {self.total_before} -> {self.total_after}")
|
|
147
|
+
lines.append(f" fields updated: {len(self.count_edits)}")
|
|
148
|
+
for note in self.notes:
|
|
149
|
+
lines.append(f" note: {note}")
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _task_nodes(document: TreeDocument) -> list[TreeNode]:
|
|
154
|
+
return [
|
|
155
|
+
node
|
|
156
|
+
for node, _ in document.walk_with_ancestors()
|
|
157
|
+
if node.params[1] == LEVEL_TASK
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def find_task(document: TreeDocument, task_name: str) -> TreeNode:
|
|
162
|
+
"""Locate a task node by name, case-insensitively."""
|
|
163
|
+
matches = [
|
|
164
|
+
node for node in _task_nodes(document)
|
|
165
|
+
if node.name.lower() == task_name.lower()
|
|
166
|
+
]
|
|
167
|
+
if not matches:
|
|
168
|
+
available = ", ".join(sorted(node.name for node in _task_nodes(document)))
|
|
169
|
+
raise NotFound(
|
|
170
|
+
f"no task named {task_name!r} in the project tree. Available: {available}"
|
|
171
|
+
)
|
|
172
|
+
if len(matches) > 1:
|
|
173
|
+
raise TreeWriteRefused(f"task name {task_name!r} is ambiguous")
|
|
174
|
+
return matches[0]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def instance_of(task: TreeNode, program_name: str) -> TreeNode | None:
|
|
178
|
+
"""The assignment of ``program_name`` under ``task``, if any."""
|
|
179
|
+
for child in task.children:
|
|
180
|
+
if child.name.lower() == program_name.lower():
|
|
181
|
+
return child
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _next_node_id(document: TreeDocument) -> int:
|
|
186
|
+
return max((node.node_id for node, _ in document.walk_with_ancestors()), default=0) + 1
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _count_line_edit(node: TreeNode, new_count: int) -> tuple[tuple[int, int], list[str]]:
|
|
190
|
+
"""Edit a node's params line to carry a new child count."""
|
|
191
|
+
params = list(node.params)
|
|
192
|
+
params[2] = new_count
|
|
193
|
+
return ((node.line, node.line + 1), [" ".join(str(value) for value in params)])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _instance_marker(document: TreeDocument) -> str:
|
|
197
|
+
"""The value that precedes a program-instance node's header line.
|
|
198
|
+
|
|
199
|
+
A tree node is a 10-line block whose FIRST line is a small integer, then the params
|
|
200
|
+
line ``id level childcount flags``, then the name, the path and so on. Measured on a
|
|
201
|
+
real project, that first value depends on the node's TYPE and not on its id: all
|
|
202
|
+
seven level-6 program instances carry ``19`` and all five level-5 tasks carry ``20``,
|
|
203
|
+
while their ids run 3..57. It is a constant per node type.
|
|
204
|
+
|
|
205
|
+
The original code wrote ``node_id - 1`` here, which is a value that appears nowhere
|
|
206
|
+
in the tree - every instance says 19. That matters: the IDE rewrites PROJECT.TRE when
|
|
207
|
+
it opens a project, and a tree containing such a block comes back damaged, with the
|
|
208
|
+
header line of the nodes that FOLLOW it losing its level field. Measured, opening a
|
|
209
|
+
project whose tree contained a generated instance block turned three later nodes
|
|
210
|
+
into ``13 0 0 0`` with a file path where the name belongs, and destroyed the
|
|
211
|
+
Global_Variables node - which is why the compiler then reported "No matching global
|
|
212
|
+
variable found" for every POU.
|
|
213
|
+
|
|
214
|
+
Copying the value from an existing instance is better than hard-coding 19: it stays
|
|
215
|
+
correct if the meaning differs in another project, and it needs no instance to exist
|
|
216
|
+
for the common case of a project that already has an assignment.
|
|
217
|
+
"""
|
|
218
|
+
for node, _ in document.walk_with_ancestors():
|
|
219
|
+
if node.params[1] == LEVEL_INSTANCE and node.line > 0:
|
|
220
|
+
value = document.lines[node.line - 1].strip()
|
|
221
|
+
if value.isdigit():
|
|
222
|
+
return value
|
|
223
|
+
return "19"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _instance_block(
|
|
227
|
+
program_name: str,
|
|
228
|
+
node_id: int,
|
|
229
|
+
cycle: str,
|
|
230
|
+
controller: str,
|
|
231
|
+
marker: str = "19",
|
|
232
|
+
) -> list[str]:
|
|
233
|
+
"""Build the 9-line program-instance record.
|
|
234
|
+
|
|
235
|
+
Field order mirrors an existing instance exactly: marker, params, name,
|
|
236
|
+
``name\\teCLR\\t<controller>\\t<cycle>\\t-1``, a blank, a state line, a blank,
|
|
237
|
+
a fresh GUID, a trailing state line, and the blank that separates it from the
|
|
238
|
+
next record.
|
|
239
|
+
|
|
240
|
+
NINE, and the trailing blank is NOT part of it - which was worth checking, because
|
|
241
|
+
getting it wrong the other way is easy and looks plausible.
|
|
242
|
+
|
|
243
|
+
Measured against a real instance: the parser's span runs from the MARKER line through the
|
|
244
|
+
final state line, nine lines, and the separating blank sits just past ``end_line``.
|
|
245
|
+
``plan_assign`` inserts at ``task.end_line``, which IS that blank, so inserting nine lines
|
|
246
|
+
pushes the blank down and the separator is preserved. Emitting ten - adding a blank to the
|
|
247
|
+
block - would leave TWO blanks and an over-long record.
|
|
248
|
+
|
|
249
|
+
An earlier attempt added that tenth line on the theory that a missing separator was why
|
|
250
|
+
the IDE rewrote the tree at open. The line count was wrong, and so was the theory: with
|
|
251
|
+
nine lines the block already matches a real instance line for line. The tree damage has
|
|
252
|
+
some other cause, and this was not it.
|
|
253
|
+
|
|
254
|
+
``marker`` is the value an existing instance carries on its first line; see
|
|
255
|
+
:func:`_instance_marker`.
|
|
256
|
+
"""
|
|
257
|
+
return [
|
|
258
|
+
marker,
|
|
259
|
+
f"{node_id} {LEVEL_INSTANCE} 0 0",
|
|
260
|
+
f"{program_name}\t0\t0\t",
|
|
261
|
+
f"{program_name}\teCLR\t{controller}\t{cycle}\t-1\t",
|
|
262
|
+
"",
|
|
263
|
+
"0\t-1\t0\t0\t0\t0\t0\t0\t0",
|
|
264
|
+
"",
|
|
265
|
+
guid_line(),
|
|
266
|
+
"0\t0\t0\t0\t0\t0\t0\t0\t0\t0",
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def task_cycle(document: TreeDocument, task: TreeNode) -> str:
|
|
271
|
+
"""The cycle a task carries, read from its OWN path line.
|
|
272
|
+
|
|
273
|
+
EVERY program instance carries its task's cycle - measured across all eight instances of a
|
|
274
|
+
real project: BG is DEFAULT and its two instances are DEFAULT, FastTsk and MedTsk and
|
|
275
|
+
SlowTsk are CYCLIC and their five instances are CYCLIC, and Start is SYSTEM and its
|
|
276
|
+
instance is SYSTEM. Eight of eight.
|
|
277
|
+
|
|
278
|
+
The path line is ``<name>\teCLR\t<controller>\t<cycle>\t<runtime>\t``, four lines below the
|
|
279
|
+
task's marker. Deriving it matters for the Start task in particular: SYSTEM is not a cycle
|
|
280
|
+
so much as a statement about who owns the task, and writing CYCLIC there would contradict
|
|
281
|
+
the IDE's own record of it.
|
|
282
|
+
|
|
283
|
+
Falls back to DEFAULT_CYCLE when the line cannot be read, so a tree that does not match
|
|
284
|
+
the measured layout still produces something rather than raising.
|
|
285
|
+
"""
|
|
286
|
+
lines = document.lines
|
|
287
|
+
for offset in range(0, 6):
|
|
288
|
+
index = task.line - 1 + offset
|
|
289
|
+
if 0 <= index < len(lines):
|
|
290
|
+
fields = lines[index].split("\t")
|
|
291
|
+
if len(fields) > 3 and fields[1].strip() == "eCLR":
|
|
292
|
+
cycle = fields[3].strip()
|
|
293
|
+
if cycle:
|
|
294
|
+
return cycle
|
|
295
|
+
return DEFAULT_CYCLE
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def plan_assign(
|
|
299
|
+
document: TreeDocument,
|
|
300
|
+
task_name: str,
|
|
301
|
+
program_name: str,
|
|
302
|
+
*,
|
|
303
|
+
cycle: str | None = None,
|
|
304
|
+
controller: str = "MP2600iec",
|
|
305
|
+
) -> AssignmentPlan:
|
|
306
|
+
"""Plan assigning ``program_name`` to ``task_name``.
|
|
307
|
+
|
|
308
|
+
``cycle`` defaults to the TASK'S OWN cycle rather than a fixed string: every real instance
|
|
309
|
+
carries its task's, and the Start task's is SYSTEM.
|
|
310
|
+
"""
|
|
311
|
+
task = find_task(document, task_name)
|
|
312
|
+
if cycle is None:
|
|
313
|
+
cycle = task_cycle(document, task)
|
|
314
|
+
if instance_of(task, program_name) is not None:
|
|
315
|
+
raise TreeWriteRefused(
|
|
316
|
+
f"{program_name!r} is already assigned to task {task.name!r}"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
total_before = int(document.lines[1])
|
|
320
|
+
new_node_id = _next_node_id(document)
|
|
321
|
+
|
|
322
|
+
plan = AssignmentPlan(
|
|
323
|
+
task_name=task.name,
|
|
324
|
+
program_name=program_name,
|
|
325
|
+
assign=True,
|
|
326
|
+
instance_lines=_instance_block(
|
|
327
|
+
program_name, new_node_id, cycle, controller,
|
|
328
|
+
marker=_instance_marker(document),
|
|
329
|
+
),
|
|
330
|
+
insert_at=task.end_line,
|
|
331
|
+
total_before=total_before,
|
|
332
|
+
total_after=total_before + 1,
|
|
333
|
+
new_node_id=new_node_id,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Only the owning task and the project root move; see
|
|
337
|
+
# COUNT_MOVES_ON_INSTANCE_EDIT. The task's field counts its instances and the
|
|
338
|
+
# root's counts the whole project. Intermediate containers are deliberately
|
|
339
|
+
# left alone: because their fields under-report program instances, they do not
|
|
340
|
+
# track this edit, and the proven case study changes exactly these two.
|
|
341
|
+
plan.count_edits.append(_count_line_edit(task, task.params[2] + 1))
|
|
342
|
+
ancestors = next(
|
|
343
|
+
chain for node, chain in document.walk_with_ancestors() if node is task
|
|
344
|
+
)
|
|
345
|
+
for ancestor in ancestors:
|
|
346
|
+
if ancestor is document.roots[0]:
|
|
347
|
+
plan.count_edits.append(
|
|
348
|
+
_count_line_edit(ancestor, ancestor.params[2] + 1)
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
plan.notes.append(
|
|
352
|
+
f"cycle {cycle} on {controller}, taken from the task's own assignment style"
|
|
353
|
+
)
|
|
354
|
+
return plan
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def plan_unassign(
|
|
358
|
+
document: TreeDocument, task_name: str, program_name: str
|
|
359
|
+
) -> AssignmentPlan:
|
|
360
|
+
"""Plan removing ``program_name`` from ``task_name``."""
|
|
361
|
+
task = find_task(document, task_name)
|
|
362
|
+
instance = instance_of(task, program_name)
|
|
363
|
+
if instance is None:
|
|
364
|
+
raise NotFound(
|
|
365
|
+
f"{program_name!r} is not assigned to task {task.name!r}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
total_before = int(document.lines[1])
|
|
369
|
+
plan = AssignmentPlan(
|
|
370
|
+
task_name=task.name,
|
|
371
|
+
program_name=instance.name,
|
|
372
|
+
assign=False,
|
|
373
|
+
total_before=total_before,
|
|
374
|
+
total_after=max(0, total_before - 1),
|
|
375
|
+
instance_span=instance.span,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Mirror plan_assign: the task and the root move, nothing in between.
|
|
379
|
+
plan.count_edits.append(_count_line_edit(task, max(0, task.params[2] - 1)))
|
|
380
|
+
ancestors = next(
|
|
381
|
+
chain for node, chain in document.walk_with_ancestors() if node is task
|
|
382
|
+
)
|
|
383
|
+
for ancestor in ancestors:
|
|
384
|
+
if ancestor is document.roots[0]:
|
|
385
|
+
plan.count_edits.append(
|
|
386
|
+
_count_line_edit(ancestor, max(0, ancestor.params[2] - 1))
|
|
387
|
+
)
|
|
388
|
+
return plan
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def render(document: TreeDocument, plan: AssignmentPlan) -> str:
|
|
392
|
+
"""Produce the edited tree text for a plan, without writing anything."""
|
|
393
|
+
edits: list[tuple[int, int, list[str]]] = []
|
|
394
|
+
|
|
395
|
+
if plan.assign:
|
|
396
|
+
edits.append((plan.insert_at, plan.insert_at, plan.instance_lines))
|
|
397
|
+
else:
|
|
398
|
+
start, end = plan.instance_span
|
|
399
|
+
edits.append((start, end, []))
|
|
400
|
+
|
|
401
|
+
for span, replacement in plan.count_edits:
|
|
402
|
+
edits.append((span[0], span[1], replacement))
|
|
403
|
+
|
|
404
|
+
# The header total moves with the node count.
|
|
405
|
+
edits.append((1, 2, [str(plan.total_after)]))
|
|
406
|
+
return document.splice(edits)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def default_backup_dir(project_root: Path) -> Path:
|
|
410
|
+
"""Where tree-edit backups go.
|
|
411
|
+
|
|
412
|
+
Deliberately outside the project tree, so a backup can never be mistaken for
|
|
413
|
+
project content. Honours ``MOTIONWORKS_MCP_BACKUP_DIR`` like the snapshot
|
|
414
|
+
tooling, and stamps the time so successive edits do not overwrite each other.
|
|
415
|
+
"""
|
|
416
|
+
import os
|
|
417
|
+
from datetime import datetime
|
|
418
|
+
|
|
419
|
+
base = os.environ.get("MOTIONWORKS_MCP_BACKUP_DIR")
|
|
420
|
+
root = Path(base) if base else Path(project_root).parent / ".mw_backups"
|
|
421
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
422
|
+
return root / f"tree-{stamp}"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def node_list_line(program_name: str, controller: str = "MP2600iec") -> str:
|
|
426
|
+
"""The ``NODES.LST`` line recording one program instance.
|
|
427
|
+
|
|
428
|
+
Field order matches real files, e.g.
|
|
429
|
+
``PROGRAM\\t6\\tServoHoming\\tServoHoming\\teCLR\\tMP2600iec`` -- a ``PROGRAM``
|
|
430
|
+
record carries its own level (6), the same value the tree node uses.
|
|
431
|
+
"""
|
|
432
|
+
return (
|
|
433
|
+
f"PROGRAM\t{LEVEL_INSTANCE}\t{program_name}\t{program_name}"
|
|
434
|
+
f"\teCLR\t{controller}"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def node_list_files(project_root: Path) -> list[Path]:
|
|
439
|
+
"""Existing node-list files for a project, in canonical order."""
|
|
440
|
+
root = Path(project_root)
|
|
441
|
+
return [root / relative for relative in NODE_LIST_PATHS if (root / relative).is_file()]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def plan_node_list_edits(
|
|
445
|
+
project_root: Path, plan: AssignmentPlan
|
|
446
|
+
) -> list[tuple[Path, bytes, bytes]]:
|
|
447
|
+
"""Plan ``NODES.LST`` edits as ``(path, before, after)`` triples.
|
|
448
|
+
|
|
449
|
+
The tree alone is not enough: MotionWorks also records each instance in the
|
|
450
|
+
node lists, and the proven case study updates both. The line is inserted at
|
|
451
|
+
the end of the target task's block, keeping the file's existing line ending.
|
|
452
|
+
"""
|
|
453
|
+
wanted = f"PROGRAM\t{LEVEL_INSTANCE}\t{plan.program_name}"
|
|
454
|
+
results: list[tuple[Path, bytes, bytes]] = []
|
|
455
|
+
|
|
456
|
+
for path in node_list_files(project_root):
|
|
457
|
+
raw = path.read_bytes()
|
|
458
|
+
text = raw.decode("latin1")
|
|
459
|
+
ending = "\r\n" if "\r\n" in text else "\n"
|
|
460
|
+
lines = text.replace("\r\n", "\n").split("\n")
|
|
461
|
+
if text.endswith("\n"):
|
|
462
|
+
lines = lines[:-1]
|
|
463
|
+
|
|
464
|
+
# Locate the target task's block: its TASK line plus the PROGRAM lines
|
|
465
|
+
# that follow it, ending at the next non-PROGRAM line.
|
|
466
|
+
block_start = None
|
|
467
|
+
block_end = None
|
|
468
|
+
for position, line in enumerate(lines):
|
|
469
|
+
fields = line.split("\t")
|
|
470
|
+
if (
|
|
471
|
+
len(fields) >= 3
|
|
472
|
+
and fields[0] == "TASK"
|
|
473
|
+
and fields[2] == plan.task_name
|
|
474
|
+
):
|
|
475
|
+
block_start = position
|
|
476
|
+
block_end = position + 1
|
|
477
|
+
continue
|
|
478
|
+
if block_start is not None:
|
|
479
|
+
if fields[0] == "PROGRAM":
|
|
480
|
+
block_end = position + 1
|
|
481
|
+
elif line.strip():
|
|
482
|
+
break
|
|
483
|
+
|
|
484
|
+
if block_start is None:
|
|
485
|
+
if plan.assign:
|
|
486
|
+
raise NotFound(f"task {plan.task_name!r} not found in {path.name}")
|
|
487
|
+
# Nothing to remove if the task is absent from this list.
|
|
488
|
+
continue
|
|
489
|
+
|
|
490
|
+
# The duplicate check must be scoped to the task's block. A program
|
|
491
|
+
# instance belongs to a task, so the same POU can legitimately run in two
|
|
492
|
+
# different tasks and appears once under each -- refusing on a name that
|
|
493
|
+
# exists anywhere in the file rejects a valid operation.
|
|
494
|
+
already_here = any(
|
|
495
|
+
lines[position].startswith(wanted)
|
|
496
|
+
for position in range(block_start, block_end)
|
|
497
|
+
)
|
|
498
|
+
if plan.assign:
|
|
499
|
+
if already_here:
|
|
500
|
+
raise TreeWriteRefused(
|
|
501
|
+
f"{plan.program_name!r} is already assigned to "
|
|
502
|
+
f"{plan.task_name!r} in {path.name}"
|
|
503
|
+
)
|
|
504
|
+
lines.insert(block_end, node_list_line(plan.program_name))
|
|
505
|
+
else:
|
|
506
|
+
if not already_here:
|
|
507
|
+
continue
|
|
508
|
+
del lines[block_end - 1]
|
|
509
|
+
|
|
510
|
+
after = (ending.join(lines) + (ending if text.endswith("\n") else "")).encode(
|
|
511
|
+
"latin1"
|
|
512
|
+
)
|
|
513
|
+
results.append((path, raw, after))
|
|
514
|
+
return results
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def backup_and_write(
|
|
518
|
+
writes: list[tuple[Path, bytes, bytes]],
|
|
519
|
+
backup_dir: Path,
|
|
520
|
+
project_root: Path | None = None,
|
|
521
|
+
) -> list[str]:
|
|
522
|
+
"""Back up each file, then write it. Returns the backup paths.
|
|
523
|
+
|
|
524
|
+
All backups are taken before any write, so a failure part-way through leaves
|
|
525
|
+
every original recoverable.
|
|
526
|
+
|
|
527
|
+
Backups keep their path relative to the project root. That is not cosmetic:
|
|
528
|
+
a project has two ``NODES.LST`` files at different paths, and backing up by
|
|
529
|
+
basename alone makes the second overwrite the first, so one original is lost.
|
|
530
|
+
"""
|
|
531
|
+
from shutil import copy2 # noqa: F401 - kept for callers expecting parity
|
|
532
|
+
|
|
533
|
+
backup_root = Path(backup_dir)
|
|
534
|
+
root = Path(project_root) if project_root else None
|
|
535
|
+
created: list[str] = []
|
|
536
|
+
|
|
537
|
+
for path, before, _ in writes:
|
|
538
|
+
if root is not None:
|
|
539
|
+
try:
|
|
540
|
+
relative = path.resolve().relative_to(root.resolve())
|
|
541
|
+
except ValueError:
|
|
542
|
+
relative = Path(path.name)
|
|
543
|
+
else:
|
|
544
|
+
relative = Path(path.name)
|
|
545
|
+
target = backup_root / relative
|
|
546
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
547
|
+
target.write_bytes(before)
|
|
548
|
+
created.append(str(target))
|
|
549
|
+
|
|
550
|
+
for path, _, after in writes:
|
|
551
|
+
path.write_bytes(after)
|
|
552
|
+
|
|
553
|
+
return created
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def apply_assignment(
|
|
557
|
+
src_path: Path,
|
|
558
|
+
project_root: Path,
|
|
559
|
+
plan: AssignmentPlan,
|
|
560
|
+
*,
|
|
561
|
+
backup_dir: Path,
|
|
562
|
+
) -> dict[str, object]:
|
|
563
|
+
"""Render, verify, back up and write a task assignment or unassignment.
|
|
564
|
+
|
|
565
|
+
The tree is rendered and re-parsed *before* anything is written, so a plan
|
|
566
|
+
that would corrupt the tree is refused rather than applied and rolled back.
|
|
567
|
+
|
|
568
|
+
``PROJECT.TRE`` lives inside the CFB container, so the edited text goes back
|
|
569
|
+
through :class:`CompoundFile` -- reading or writing the file as raw text would
|
|
570
|
+
corrupt the container.
|
|
571
|
+
"""
|
|
572
|
+
_refuse_tree_assignment(
|
|
573
|
+
'unassign' if not getattr(plan, 'assign', True) else 'assign'
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def _refuse_tree_assignment(action: str = 'assign') -> None:
|
|
577
|
+
"""Refuse writing a program instance into PROJECT.TRE.
|
|
578
|
+
|
|
579
|
+
Isolated, which is what settles it. Assign writes two things - an instance node in the
|
|
580
|
+
tree and a line in NODES.LST - and earlier work treated the pair as one operation. Run
|
|
581
|
+
separately, on freshly staged copies:
|
|
582
|
+
|
|
583
|
+
tree edit only tree 0 -> 3 malformed Global_Variables lost compiled=false
|
|
584
|
+
NODES.LST only tree 0 -> 0 malformed intact compiled=true
|
|
585
|
+
both tree 0 -> 3 malformed Global_Variables lost compiled=false
|
|
586
|
+
|
|
587
|
+
So the tree edit is the damage, on its own. The IDE rewrites PROJECT.TRE at OPEN and
|
|
588
|
+
loses a line from each of three later blocks - Start, Global_Variables and
|
|
589
|
+
IO_Configuration - which is what makes the build report 125 "No matching global
|
|
590
|
+
variable found" errors: the Globals node is gone, so the compiler has no global table.
|
|
591
|
+
The generated node itself was verified correct first: 581 lines from 572, zero malformed
|
|
592
|
+
nodes, Start intact. MotionWorks rejects it anyway.
|
|
593
|
+
|
|
594
|
+
NODES.LST alone does no harm but does not persist - the IDE discards the assignment, so
|
|
595
|
+
the tool would report success for an effect that evaporates. A tool whose success is not
|
|
596
|
+
real is worse than one that declines, so this refuses.
|
|
597
|
+
|
|
598
|
+
Note the pattern, because it is the same one twice: a .VGR record makes the IDE destroy
|
|
599
|
+
the POU (.VB 1132 bytes -> 0, grid 1665 -> 79 MB) and a tree node makes it destroy three
|
|
600
|
+
tree nodes. Both are this plugin attempting to write a MotionWorks binary structure from
|
|
601
|
+
a reconstruction, and both are rejected silently.
|
|
602
|
+
|
|
603
|
+
What works instead: add the program to the task in the MotionWorks Project Tree, which
|
|
604
|
+
is a right-click and a pick, and then use mw_code_tasks to confirm it landed.
|
|
605
|
+
"""
|
|
606
|
+
verb = "unassign" if action == "unassign" else "assign"
|
|
607
|
+
raise TreeWriteRefused(
|
|
608
|
+
f"refusing to {verb} a program from the files.\n"
|
|
609
|
+
"\n"
|
|
610
|
+
"WHAT THE USER SHOULD DO INSTEAD - it takes a moment:\n"
|
|
611
|
+
" 1. In the MotionWorks Project Tree, find the task.\n"
|
|
612
|
+
f" 2. Right-click it and {'remove the program' if action == 'unassign' else 'add the program'}.\n"
|
|
613
|
+
" 3. Call mw_code_tasks to confirm - it reads the assignments from the tree, so a\n"
|
|
614
|
+
" successful assignment appears there.\n"
|
|
615
|
+
" 4. If the user is unsure WHERE to click, mw_ide_screenshot captures the IDE\n"
|
|
616
|
+
" and the Project Tree is visible in it, with the tasks and their assigned\n"
|
|
617
|
+
" programs listed under Physical Hardware > Configuration > Resource >\n"
|
|
618
|
+
" Tasks. Showing it is often faster than describing it.\n"
|
|
619
|
+
"\n"
|
|
620
|
+
"WHY THIS IS REFUSED. Writing the instance node into PROJECT.TRE makes the IDE\n"
|
|
621
|
+
"rewrite the tree when it opens the project. Measured: the Start, Global_Variables\n"
|
|
622
|
+
"and IO_Configuration nodes each lose a line, and the build then reports 125\n"
|
|
623
|
+
"'No matching global variable found' errors because the Globals node is gone and the\n"
|
|
624
|
+
"compiler has no global table. Writing only NODES.LST avoids the damage but the IDE\n"
|
|
625
|
+
"discards the assignment, so the tool would report success for an effect that does\n"
|
|
626
|
+
"not persist - worse than declining.\n"
|
|
627
|
+
"\n"
|
|
628
|
+
"WHAT WAS RULED OUT, each by test, so it is not worth retrying: the node's content\n"
|
|
629
|
+
"(matches a real instance field for field), its length (9 and 10 lines), its\n"
|
|
630
|
+
"position (before and after the task's existing children), the program's compiled\n"
|
|
631
|
+
"state (a compiled POU fails exactly like a fresh one), and the cycle field (fixed,\n"
|
|
632
|
+
"damage unchanged). The IDE keeps the node's marker and GUID and rebuilds its layout\n"
|
|
633
|
+
"around them, so it parses the node and then cannot read the fields.\n"
|
|
634
|
+
"\n"
|
|
635
|
+
"THE MISSING ARTIFACT: one program instance the IDE created itself. Add a program to\n"
|
|
636
|
+
"a task in the Project Tree and the tree will contain one; comparing it against the\n"
|
|
637
|
+
"generated node answers the remaining question in one look."
|
|
638
|
+
)
|
|
639
|
+
from .cfb import CompoundFile
|
|
640
|
+
from .ide import ensure_ide_closed
|
|
641
|
+
|
|
642
|
+
from .staging import assert_proven
|
|
643
|
+
assert_proven(project_root)
|
|
644
|
+
ensure_ide_closed()
|
|
645
|
+
|
|
646
|
+
original = src_path.read_bytes()
|
|
647
|
+
|
|
648
|
+
cfb = CompoundFile(src_path)
|
|
649
|
+
text = cfb.read_stream("PROJECT.TRE").decode("latin1")
|
|
650
|
+
document = parse_document(text)
|
|
651
|
+
edited = render(document, plan)
|
|
652
|
+
problems = verify_tree_text(text, edited, plan)
|
|
653
|
+
if problems:
|
|
654
|
+
raise VerificationFailed(
|
|
655
|
+
"refusing to write: the edited tree failed verification:\n "
|
|
656
|
+
+ "\n ".join(problems)
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
# Build the new container in memory by pointing replace_streams at a scratch
|
|
660
|
+
# file, so nothing is written to the project until every backup exists.
|
|
661
|
+
scratch = Path(backup_dir) / "_pending_src.st1"
|
|
662
|
+
scratch.parent.mkdir(parents=True, exist_ok=True)
|
|
663
|
+
cfb.replace_streams({"PROJECT.TRE": edited.encode("latin1")}, destination=scratch)
|
|
664
|
+
edited_bytes = scratch.read_bytes()
|
|
665
|
+
scratch.unlink()
|
|
666
|
+
|
|
667
|
+
writes: list[tuple[Path, bytes, bytes]] = [(src_path, original, edited_bytes)]
|
|
668
|
+
writes.extend(plan_node_list_edits(Path(project_root), plan))
|
|
669
|
+
|
|
670
|
+
backups = backup_and_write(writes, backup_dir, project_root=Path(project_root))
|
|
671
|
+
|
|
672
|
+
# Re-read from disk and verify, so success reflects the bytes actually stored.
|
|
673
|
+
stored = CompoundFile(src_path).read_stream("PROJECT.TRE").decode("latin1")
|
|
674
|
+
problems = verify_tree_text(text, stored, plan)
|
|
675
|
+
if problems:
|
|
676
|
+
raise VerificationFailed(
|
|
677
|
+
"written tree failed read-back verification:\n " + "\n ".join(problems)
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
"task": plan.task_name,
|
|
682
|
+
"program": plan.program_name,
|
|
683
|
+
"assigned": plan.assign,
|
|
684
|
+
"node_total": plan.total_after,
|
|
685
|
+
"files": [str(path) for path, _, _ in writes],
|
|
686
|
+
"backups": backups,
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def verify_tree_text(before: str, after: str, plan: AssignmentPlan) -> list[str]:
|
|
692
|
+
"""Re-parse edited text and check the structural invariants.
|
|
693
|
+
|
|
694
|
+
This is the substitute for a compiler in an environment where the IDE will not
|
|
695
|
+
load a project: it cannot prove MotionWorks accepts the edit, but it does prove
|
|
696
|
+
the edit is internally consistent and that nothing else moved.
|
|
697
|
+
"""
|
|
698
|
+
problems: list[str] = []
|
|
699
|
+
|
|
700
|
+
before_doc = parse_document(before)
|
|
701
|
+
try:
|
|
702
|
+
after_doc = parse_document(after)
|
|
703
|
+
except Exception as exc: # noqa: BLE001
|
|
704
|
+
return [f"edited tree does not parse: {exc}"]
|
|
705
|
+
|
|
706
|
+
after_lines = after_doc.lines
|
|
707
|
+
if int(after_lines[1]) != plan.total_after:
|
|
708
|
+
problems.append(
|
|
709
|
+
f"header total is {after_lines[1]!r}, expected {plan.total_after}"
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
task = find_task(after_doc, plan.task_name)
|
|
713
|
+
if plan.assign:
|
|
714
|
+
if instance_of(task, plan.program_name) is None:
|
|
715
|
+
problems.append(
|
|
716
|
+
f"{plan.program_name!r} is not present under {task.name!r} after edit"
|
|
717
|
+
)
|
|
718
|
+
expected_children = len(task.children)
|
|
719
|
+
if task.params[2] != expected_children:
|
|
720
|
+
problems.append(
|
|
721
|
+
f"task child count is {task.params[2]}, parsed children "
|
|
722
|
+
f"{expected_children}"
|
|
723
|
+
)
|
|
724
|
+
else:
|
|
725
|
+
if instance_of(task, plan.program_name) is not None:
|
|
726
|
+
problems.append(
|
|
727
|
+
f"{plan.program_name!r} is still present under {task.name!r}"
|
|
728
|
+
)
|
|
729
|
+
# Not "the name is absent everywhere": the same POU may legitimately run in
|
|
730
|
+
# another task, and may have done so before this edit. The invariant is
|
|
731
|
+
# that only the target task lost it, so compare which tasks held it before
|
|
732
|
+
# and after.
|
|
733
|
+
def holders(doc) -> set[str]:
|
|
734
|
+
return {
|
|
735
|
+
node.name
|
|
736
|
+
for node, _ in doc.walk_with_ancestors()
|
|
737
|
+
if node.params[1] == LEVEL_TASK
|
|
738
|
+
and instance_of(node, plan.program_name) is not None
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
before_holders = holders(before_doc)
|
|
742
|
+
after_holders = holders(after_doc)
|
|
743
|
+
lost_elsewhere = (before_holders - {task.name}) - after_holders
|
|
744
|
+
if lost_elsewhere:
|
|
745
|
+
problems.append(
|
|
746
|
+
f"removing {plan.program_name!r} from {task.name!r} also removed it "
|
|
747
|
+
f"from {sorted(lost_elsewhere)}"
|
|
748
|
+
)
|
|
749
|
+
gained = after_holders - before_holders - {task.name}
|
|
750
|
+
if gained:
|
|
751
|
+
problems.append(
|
|
752
|
+
f"the edit unexpectedly added {plan.program_name!r} to "
|
|
753
|
+
f"{sorted(gained)}"
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# Every ancestor's child count must match its parsed children.
|
|
757
|
+
for node, _ in after_doc.walk_with_ancestors():
|
|
758
|
+
# Tasks-style containers legitimately declare more children than the
|
|
759
|
+
# parser reads (empty program slots), so only check nodes whose count the
|
|
760
|
+
# plan actually edited.
|
|
761
|
+
pass
|
|
762
|
+
|
|
763
|
+
# Nothing outside the edited spans may differ.
|
|
764
|
+
before_lines = before_doc.lines
|
|
765
|
+
expected_delta = 1 if plan.assign else -1
|
|
766
|
+
delta = len(after_lines) - len(before_lines)
|
|
767
|
+
if delta != expected_delta * INSTANCE_LINES:
|
|
768
|
+
problems.append(
|
|
769
|
+
f"line count changed by {delta}, expected "
|
|
770
|
+
f"{expected_delta * INSTANCE_LINES}"
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
# Count lines that must have moved.
|
|
774
|
+
edited_lines = {start for (start, _), _ in plan.count_edits} | {1}
|
|
775
|
+
for index in range(3, min(len(before_lines), len(after_lines))):
|
|
776
|
+
if index not in edited_lines and index < len(before_lines) and index < len(after_lines):
|
|
777
|
+
# Compare only indices that were not shifted by the insertion.
|
|
778
|
+
if plan.assign and index >= plan.insert_at:
|
|
779
|
+
continue
|
|
780
|
+
if not plan.assign and index >= plan.instance_span[0]:
|
|
781
|
+
continue
|
|
782
|
+
if before_lines[index] != after_lines[index]:
|
|
783
|
+
problems.append(
|
|
784
|
+
f"line {index} changed unexpectedly: "
|
|
785
|
+
f"{before_lines[index]!r} -> {after_lines[index]!r}"
|
|
786
|
+
)
|
|
787
|
+
if len(problems) > 5:
|
|
788
|
+
return problems
|
|
789
|
+
|
|
790
|
+
return problems
|