Skip to content

Commit 866092a

Browse files
committed
Add standards-based OpenPLC target foundation
1 parent 240f531 commit 866092a

6 files changed

Lines changed: 172 additions & 1 deletion

File tree

ARCHITECTURE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ conversion or target layers. Exporter-to-target imports are restricted to
8787
documented compatibility surfaces, and public package exports must be unique
8888
and resolvable.
8989

90+
`targets.openplc` exposes a standards-only PLCopen 2.01 façade with no direct
91+
CODESYS dependency or emitted CODESYS metadata. Native OpenPLC import/runtime
92+
compatibility remains an evidence milestone rather than an inferred claim.
93+
9094
## Invariants
9195

9296
- Unknown source data is preserved.

docs/roadmaps/architecture-refactoring-roadmap.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,9 @@ Deferred until another device profile or target exists:
305305
- [x] Review public `__init__` re-exports as APIs grow
306306
- [x] Stop tracking generated `*.egg-info` metadata in a dedicated cleanup
307307
commit
308-
- [ ] Add an OpenPLC target without importing CODESYS assumptions
308+
- [x] Add an OpenPLC target without importing CODESYS assumptions
309+
- [ ] Validate generated standard PLCopen XML through a native OpenPLC import
310+
and runtime smoke test
309311
- [ ] Revisit physical channel and CIP assembly entities when EDS or live
310312
evidence supports them
311313
- [ ] Review this document whenever a module gains a second independent
@@ -322,6 +324,12 @@ and `ir` layers and restrict exporter-to-target imports to the documented
322324
package façade and legacy compatibility shim. Public exporter and CODESYS
323325
target `__all__` surfaces are checked for duplicate or unresolved names.
324326

327+
`targets.openplc.OpenPLCExporter` now provides a minimal standards-based
328+
target façade over PLCopen XML 2.01. It emits no CODESYS extensions and is
329+
byte-identical to the generic standard profile. This is an architecture
330+
foundation, not yet a claim of native OpenPLC import compatibility; that
331+
requires an external import and runtime smoke test.
332+
325333
## Refactor completion checklist
326334

327335
A refactor is complete only when:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""OpenPLC target adapters using standards-based PLCopen XML."""
2+
3+
from .exporter import OpenPLCExporter
4+
5+
__all__ = ["OpenPLCExporter"]
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""OpenPLC export foundation backed by standard PLCopen XML 2.01."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime
6+
from pathlib import Path
7+
import xml.etree.ElementTree as ET
8+
9+
from twinforge.exporters.plcopen import PLCopenExporter
10+
from twinforge.exporters.plcopen_types import (
11+
PLCopenExportResult,
12+
PLCopenProfile,
13+
)
14+
from twinforge.model import Controller
15+
16+
17+
class OpenPLCExporter:
18+
"""Emit standard PLCopen XML for native OpenPLC evaluation.
19+
20+
No unverified OpenPLC extensions are added. Native import compatibility
21+
remains a separate evidence milestone.
22+
"""
23+
24+
def __init__(self) -> None:
25+
self._plcopen = PLCopenExporter(PLCopenProfile.STANDARD_201)
26+
27+
def build(
28+
self,
29+
controller: Controller,
30+
*,
31+
project_name: str | None = None,
32+
creation_time: datetime | None = None,
33+
) -> ET.Element:
34+
"""Build a standard PLCopen 2.01 document."""
35+
36+
return self._plcopen.build(
37+
controller,
38+
project_name=project_name,
39+
creation_time=creation_time,
40+
)
41+
42+
def export(
43+
self,
44+
controller: Controller,
45+
*,
46+
destination: str | Path | None = None,
47+
project_name: str | None = None,
48+
creation_time: datetime | None = None,
49+
) -> PLCopenExportResult:
50+
"""Serialize standard PLCopen XML without CODESYS extensions."""
51+
52+
return self._plcopen.export(
53+
controller,
54+
destination=destination,
55+
project_name=project_name,
56+
creation_time=creation_time,
57+
)

tests/test_architecture_dependencies.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def test_exporter_to_target_imports_are_compatibility_only() -> None:
8585
[
8686
"twinforge.exporters",
8787
"twinforge.targets.codesys",
88+
"twinforge.targets.openplc",
8889
],
8990
)
9091
def test_public_exports_are_unique_and_resolvable(module_name: str) -> None:

tests/test_openplc_exporter.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import ast
2+
from datetime import datetime, timezone
3+
import inspect
4+
import xml.etree.ElementTree as ET
5+
6+
from twinforge.exporters import (
7+
PLCOPEN_201_NAMESPACE,
8+
PLCopenExporter,
9+
PLCopenProfile,
10+
)
11+
from twinforge.model import (
12+
Controller,
13+
Identity,
14+
LadderRung,
15+
Program,
16+
Routine,
17+
Tag,
18+
Task,
19+
)
20+
from twinforge.targets.openplc import OpenPLCExporter
21+
from twinforge.targets.openplc import exporter as openplc_module
22+
23+
24+
FIXED_TIME = datetime(2026, 7, 30, tzinfo=timezone.utc)
25+
26+
27+
def _controller() -> Controller:
28+
controller = Controller(name="OpenPLCTest", identity=Identity())
29+
controller.add_tag(Tag(name="Enable", data_type="BOOL"))
30+
controller.add_tag(Tag(name="Output", data_type="BOOL"))
31+
program = Program(name="PLC_PRG")
32+
routine = Routine(name="MainRoutine", language="RLL")
33+
routine.ladder_rungs.append(
34+
LadderRung(number=0, text="XIC(Enable)OTE(Output);")
35+
)
36+
program.add_routine(routine)
37+
controller.add_program(program)
38+
controller.add_task(
39+
Task(
40+
name="MainTask",
41+
task_type="Periodic",
42+
rate=20,
43+
priority=1,
44+
scheduled_program_names=[program.name],
45+
scheduled_programs=[program],
46+
)
47+
)
48+
return controller
49+
50+
51+
def test_openplc_target_matches_standard_plcopen_profile() -> None:
52+
controller = _controller()
53+
expected = PLCopenExporter(PLCopenProfile.STANDARD_201).export(
54+
controller,
55+
project_name="OpenPLC Project",
56+
creation_time=FIXED_TIME,
57+
)
58+
59+
actual = OpenPLCExporter().export(
60+
controller,
61+
project_name="OpenPLC Project",
62+
creation_time=FIXED_TIME,
63+
)
64+
65+
assert actual.xml == expected.xml
66+
assert actual.diagnostics == expected.diagnostics
67+
68+
69+
def test_openplc_document_contains_no_codesys_extensions() -> None:
70+
result = OpenPLCExporter().export(
71+
_controller(),
72+
creation_time=FIXED_TIME,
73+
)
74+
root = ET.fromstring(result.xml)
75+
76+
assert root.tag == f"{{{PLCOPEN_201_NAMESPACE}}}project"
77+
assert "3s-software.com" not in result.xml
78+
assert "ProjectStructure" not in result.xml
79+
assert "ObjectId" not in result.xml
80+
81+
82+
def test_openplc_adapter_has_no_direct_codesys_import() -> None:
83+
tree = ast.parse(inspect.getsource(openplc_module))
84+
imported = {
85+
alias.name
86+
for node in ast.walk(tree)
87+
if isinstance(node, ast.Import)
88+
for alias in node.names
89+
}
90+
imported.update(
91+
node.module or ""
92+
for node in ast.walk(tree)
93+
if isinstance(node, ast.ImportFrom)
94+
)
95+
96+
assert not any("codesys" in module.casefold() for module in imported)

0 commit comments

Comments
 (0)