Skip to content

Commit 1398400

Browse files
committed
Add validated OpenPLC export planning
Add versioned OpenPLC target configuration with located-variable and telemetry mappings plus explicit CLI overrides. Add side-effect-free dry-run readiness for every installed export target and in-memory native OpenPLC project planning.
1 parent caf9604 commit 1398400

10 files changed

Lines changed: 334 additions & 29 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,11 @@ twinforge export project.L5X --target plcopen --output project.xml `
9999
twinforge export project.L5X --target codesys --output codesys.xml
100100
twinforge export project.L5X --target openplc --output build\openplc `
101101
--compile-only
102+
twinforge export project.L5X --target openplc --output build\openplc `
103+
--config openplc-export.json
102104
twinforge export project.L5X --target automationml --output plant.aml `
103105
--base-library reference\AutomationML\AutomationML2.10BaseLibraries.aml
106+
twinforge export project.L5X --target plcopen --output project.xml --dry-run
104107
```
105108

106109
`state init` refuses to overwrite an existing path. Validation and inspection
@@ -131,14 +134,21 @@ directory. It currently admits one scheduled program with one RLL routine and
131134
the tested Boolean, timer, and counter subset; unsupported semantics fail before
132135
project files are written. `--compile-only` sets that device configuration mode.
133136
Advanced located-variable and telemetry mappings remain available through the
134-
Python API pending a validated CLI configuration document.
137+
Python API and a strict, versioned JSON configuration supplied with `--config`.
138+
See `examples/OpenPLC/openplc-export.example.json`. Explicit
139+
`--compile-only` or `--no-compile-only` options override the configured value.
135140

136141
`export --target automationml` writes AutomationML 2.1 / CAEX 3.0 and requires
137142
the official AutomationML base-library file. An optional `--plcopen-reference`
138143
links the controller to an existing PLCopen document, while `--xsd` performs
139144
CAEX validation. File references are made relative to the output document and
140145
are semantically resolved before anything is written.
141146

147+
Add `--dry-run` to any export target to execute parsing, target planning,
148+
configuration validation, XSD or semantic validation, and diagnostic reporting
149+
without writing the requested output. Native OpenPLC planning is performed
150+
entirely in memory rather than through a disposable project directory.
151+
142152
The example scripts remain useful as focused developer demonstrations of the
143153
same parser, analysis, and exporter APIs.
144154

ROADMAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,9 @@ The immediate structural priorities are maintained in the
150150
- [x] Add AutomationML export with semantic references and optional CAEX XSD
151151
- [ ] Preserve the example scripts as focused demonstrations or thin CLI
152152
wrappers
153-
- [ ] Add Pydantic-validated target configuration files and explicit command
154-
overrides
155-
- [ ] Report conversion readiness and unsupported semantics before writing
153+
- [x] Add versioned OpenPLC target configuration with explicit CLI overrides
154+
- [ ] Generalize validated configuration to the remaining export targets
155+
- [x] Add side-effect-free pre-export readiness through `export --dry-run`
156156
target output
157157
- [ ] Provide stable process exit codes and optional machine-readable
158158
diagnostics for automation and CI
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"schema_version": "1.0",
3+
"target": "openplc",
4+
"compile_only": true,
5+
"locations": {
6+
"Enable": "%QX0.0",
7+
"Output": "%QX0.1"
8+
},
9+
"timer_elapsed_locations": {},
10+
"counter_accumulator_locations": {},
11+
"counter_status_locations": {}
12+
}

src/twinforge/cli/export_config.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Versioned validation models for installed export configuration files."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
from typing import Literal
8+
9+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
10+
11+
12+
class ExportConfigurationError(ValueError):
13+
"""Raised when a target configuration document is unreadable or invalid."""
14+
15+
16+
class OpenPLCExportConfig(BaseModel):
17+
"""Validated options for the runtime-evidenced native OpenPLC target."""
18+
19+
model_config = ConfigDict(extra="forbid", frozen=True)
20+
21+
schema_version: Literal["1.0"]
22+
target: Literal["openplc"]
23+
compile_only: bool = False
24+
locations: dict[str, str] = Field(default_factory=dict)
25+
timer_elapsed_locations: dict[str, str] = Field(default_factory=dict)
26+
counter_accumulator_locations: dict[str, str] = Field(default_factory=dict)
27+
counter_status_locations: dict[str, dict[str, str]] = Field(
28+
default_factory=dict
29+
)
30+
31+
32+
def load_openplc_export_config(path: Path) -> OpenPLCExportConfig:
33+
"""Load one strict, versioned OpenPLC JSON configuration document."""
34+
try:
35+
value = json.loads(path.read_text(encoding="utf-8"))
36+
return OpenPLCExportConfig.model_validate(value)
37+
except (OSError, json.JSONDecodeError, ValidationError) as error:
38+
raise ExportConfigurationError(
39+
f"invalid OpenPLC export configuration '{path}': {error}"
40+
) from error

src/twinforge/cli/l5x_export.py

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
OpenPLCNativeUnsupportedError,
2727
)
2828

29+
from .export_config import OpenPLCExportConfig, load_openplc_export_config
30+
2931

3032
class L5XExportError(RuntimeError):
3133
"""Raised when an installed L5X export operation cannot complete."""
@@ -43,22 +45,42 @@ def export_l5x_target(
4345
target: str,
4446
destination: Path,
4547
schema_path: Path | None,
46-
compile_only: bool,
48+
compile_only: bool | None,
49+
config_path: Path | None,
4750
base_library_path: Path | None,
4851
plcopen_reference: Path | None,
52+
dry_run: bool,
4953
stdout: TextIO,
5054
) -> None:
5155
"""Export a Controller L5X using one explicit target adapter."""
5256
try:
5357
if target == "openplc":
58+
config = (
59+
load_openplc_export_config(config_path)
60+
if config_path is not None
61+
else OpenPLCExportConfig(
62+
schema_version="1.0",
63+
target="openplc",
64+
)
65+
)
5466
_export_openplc_native(
5567
source,
5668
destination=destination,
5769
schema_path=schema_path,
58-
compile_only=compile_only,
70+
compile_only=(
71+
compile_only
72+
if compile_only is not None
73+
else config.compile_only
74+
),
75+
config=config,
76+
dry_run=dry_run,
5977
stdout=stdout,
6078
)
6179
return
80+
if config_path is not None:
81+
raise L5XExportError(
82+
"--config currently applies only to --target openplc"
83+
)
6284
if target == "automationml":
6385
_export_automationml(
6486
source,
@@ -67,6 +89,7 @@ def export_l5x_target(
6789
compile_only=compile_only,
6890
base_library_path=base_library_path,
6991
plcopen_reference=plcopen_reference,
92+
dry_run=dry_run,
7093
stdout=stdout,
7194
)
7295
return
@@ -78,7 +101,7 @@ def export_l5x_target(
78101
)
79102

80103
profile = _PROFILES[target]
81-
if compile_only:
104+
if compile_only is not None:
82105
raise L5XExportError(
83106
"--compile-only applies only to --target openplc"
84107
)
@@ -101,8 +124,9 @@ def export_l5x_target(
101124
if schema_path is not None:
102125
validate_plcopen_xml(result.xml, schema_path)
103126

104-
destination.parent.mkdir(parents=True, exist_ok=True)
105-
destination.write_text(result.xml, encoding="utf-8")
127+
if not dry_run:
128+
destination.parent.mkdir(parents=True, exist_ok=True)
129+
destination.write_text(result.xml, encoding="utf-8")
106130
except L5XExportError:
107131
raise
108132
except (
@@ -124,7 +148,8 @@ def export_l5x_target(
124148
if profile is PLCopenProfile.STANDARD_201
125149
else "CODESYS PLCopen XML"
126150
)
127-
stdout.write(f"Exported {label} to {destination}\n")
151+
verb = "Ready to export" if dry_run else "Exported"
152+
stdout.write(f"{verb} {label} to {destination}\n")
128153
for diagnostic in [*document.diagnostics, *result.diagnostics]:
129154
object_name = (
130155
f" [{diagnostic.object_name}]" if diagnostic.object_name else ""
@@ -140,13 +165,14 @@ def _export_automationml(
140165
*,
141166
destination: Path,
142167
schema_path: Path | None,
143-
compile_only: bool,
168+
compile_only: bool | None,
144169
base_library_path: Path | None,
145170
plcopen_reference: Path | None,
171+
dry_run: bool,
146172
stdout: TextIO,
147173
) -> None:
148174
"""Write a semantically validated AutomationML 2.1 document."""
149-
if compile_only:
175+
if compile_only is not None:
150176
raise L5XExportError(
151177
"--compile-only applies only to --target openplc"
152178
)
@@ -181,10 +207,12 @@ def _export_automationml(
181207
if schema_path is not None:
182208
validate_automationml_xml(result.xml, schema_path)
183209
validate_automationml_references(result.xml, destination)
184-
destination.parent.mkdir(parents=True, exist_ok=True)
185-
destination.write_text(result.xml, encoding="utf-8")
210+
if not dry_run:
211+
destination.parent.mkdir(parents=True, exist_ok=True)
212+
destination.write_text(result.xml, encoding="utf-8")
186213

187-
stdout.write(f"Exported AutomationML 2.1 to {destination}\n")
214+
verb = "Ready to export" if dry_run else "Exported"
215+
stdout.write(f"{verb} AutomationML 2.1 to {destination}\n")
188216
stdout.write(f"Base library reference: {base_reference}\n")
189217
if plcopen_path is not None:
190218
stdout.write(f"PLCopen document reference: {plcopen_path}\n")
@@ -209,6 +237,8 @@ def _export_openplc_native(
209237
destination: Path,
210238
schema_path: Path | None,
211239
compile_only: bool,
240+
config: OpenPLCExportConfig,
241+
dry_run: bool,
212242
stdout: TextIO,
213243
) -> None:
214244
"""Write the runtime-evidenced native OpenPLC project structure."""
@@ -222,19 +252,40 @@ def _export_openplc_native(
222252
raise L5XExportError(
223253
"openplc export currently requires a Controller L5X target; "
224254
f"found {document.target_type.value}"
225-
)
226-
result = OpenPLCNativeProjectExporter().export(
255+
)
256+
exporter = OpenPLCNativeProjectExporter()
257+
plan = exporter.plan(
227258
document.target,
228-
destination=destination,
229259
project_name=document.target_name,
230260
compile_only=compile_only,
261+
locations=config.locations,
262+
timer_elapsed_locations=config.timer_elapsed_locations,
263+
counter_accumulator_locations=config.counter_accumulator_locations,
264+
counter_status_locations=config.counter_status_locations,
231265
)
232-
stdout.write(f"Exported native OpenPLC project to {result.destination}\n")
266+
if dry_run:
267+
files = tuple(destination / path for path in plan.documents)
268+
else:
269+
result = exporter.export(
270+
document.target,
271+
destination=destination,
272+
project_name=document.target_name,
273+
compile_only=compile_only,
274+
locations=config.locations,
275+
timer_elapsed_locations=config.timer_elapsed_locations,
276+
counter_accumulator_locations=(
277+
config.counter_accumulator_locations
278+
),
279+
counter_status_locations=config.counter_status_locations,
280+
)
281+
files = result.files
282+
verb = "Ready to export" if dry_run else "Exported"
283+
stdout.write(f"{verb} native OpenPLC project to {destination}\n")
233284
stdout.write(
234-
f"Source program {result.source_program_name} was lowered as "
235-
f"{result.native_program_name}.\n"
285+
f"Source program {plan.source_program_name} was lowered as "
286+
f"{plan.native_program_name}.\n"
236287
)
237-
for path in result.files:
288+
for path in files:
238289
stdout.write(f"- {path}\n")
239290
for diagnostic in document.diagnostics:
240291
object_name = (

src/twinforge/cli/main.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,14 @@ def build_parser() -> argparse.ArgumentParser:
7171
)
7272
export_l5x_command.add_argument(
7373
"--compile-only",
74-
action="store_true",
75-
help="Set compile-only mode in a native OpenPLC project.",
74+
action=argparse.BooleanOptionalAction,
75+
default=None,
76+
help="Override native OpenPLC compile-only mode.",
77+
)
78+
export_l5x_command.add_argument(
79+
"--config",
80+
type=Path,
81+
help="Versioned JSON target configuration file.",
7682
)
7783
export_l5x_command.add_argument(
7884
"--base-library",
@@ -84,6 +90,11 @@ def build_parser() -> argparse.ArgumentParser:
8490
type=Path,
8591
help="Optional PLCopen document referenced by AutomationML.",
8692
)
93+
export_l5x_command.add_argument(
94+
"--dry-run",
95+
action="store_true",
96+
help="Validate and plan the export without writing output.",
97+
)
8798

8899
state = commands.add_parser(
89100
"state",
@@ -147,8 +158,10 @@ def main(
147158
destination=arguments.output,
148159
schema_path=arguments.xsd,
149160
compile_only=arguments.compile_only,
161+
config_path=arguments.config,
150162
base_library_path=arguments.base_library,
151163
plcopen_reference=arguments.plcopen_reference,
164+
dry_run=arguments.dry_run,
152165
stdout=output,
153166
)
154167
elif arguments.state_command == "init":

src/twinforge/targets/openplc/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
from .exporter import OpenPLCExporter
44
from .native_project import (
55
OpenPLCNativeProjectExporter,
6+
OpenPLCNativeProjectPlan,
67
OpenPLCNativeProjectResult,
78
OpenPLCNativeUnsupportedError,
89
)
910

1011
__all__ = [
1112
"OpenPLCExporter",
1213
"OpenPLCNativeProjectExporter",
14+
"OpenPLCNativeProjectPlan",
1315
"OpenPLCNativeProjectResult",
1416
"OpenPLCNativeUnsupportedError",
1517
]

0 commit comments

Comments
 (0)