The PLC program from the old repo's 04-plc/, flattened into one folder and
checked against the running system.
Verified during the move:
- build.py regenerates register-map.csv byte-identically (69 points)
- polled the live PLC: the SIMULATION build is what is deployed and
running, %MW21=2 wet weather, values moving, run hours accumulating
- addresses, %MW HR1024 segmentation and %QW17/%QW7 signedness all
match the map
Corrections against the old repo:
- 10_globals.st header cited WRPS-CTL-002 (the FDS); it means CTL-003
- build.py wrote the map to its parent directory; now beside itself
- deploy/README.md was a single-file folder; now DEPLOY.md
- dropped the empty editor-devices/remote/
- README no longer claims the simulation build is uncompiled - it is
the one running
Two open items are now stated plainly rather than buried:
- none of the 20 acceptance tests in CTL-003 have ever been run
- the OpenPLC Editor lived only on the retired dev-ubuntu host, so
there is currently NO route to deploy a new program (DEPLOY.md 0)
Documents the setpoint distinction: IO_MUX seeds %MW defaults once at
first scan, operators retune them live, and that tuning exists only in
the container volume - a restart reverts it.
351 lines
12 KiB
Python
351 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate an OpenPLC Editor v4 project from src/*.st.
|
|
|
|
python gen_project.py --out editor-project
|
|
|
|
The Editor v4 project is a *folder*, not a file:
|
|
|
|
project.json meta + configuration (tasks, instances, globals)
|
|
pous/programs/*.st one file per PROGRAM
|
|
pous/function-blocks/*.st
|
|
pous/functions/*.st
|
|
|
|
There is no "import a flat .st" path in the Editor, so this script turns
|
|
the repo's ST sources into that layout. The repo stays canonical; the
|
|
generated project is a build artefact and is git-ignored.
|
|
|
|
What it does NOT touch, because the user configures them in the GUI:
|
|
devices/configuration.json (runtime address, credentials) and build/.
|
|
Copy only project.json and pous/ over an existing project.
|
|
|
|
Schemas below were recovered from the Editor bundle
|
|
(resources/app.asar, v4.2.11), not guessed:
|
|
|
|
scalar : {"definition": "base-type", "value": "INT"}
|
|
array : {"definition": "array",
|
|
"value": "ARRAY[1..3] OF REAL",
|
|
"data": {"baseType": {"definition": "base-type", "value": "REAL"},
|
|
"dimensions": [{"dimension": "1..3"}]}}
|
|
class : one of input|output|inOut|external|local|temp|global
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
SRC = ROOT / "src"
|
|
|
|
GLOBALS_FILE = "10_globals.st"
|
|
CONFIG_FILE = {"field": "90_config_field.st", "sim": "91_config_sim.st"}
|
|
|
|
# src file -> POU folder. Anything not listed is not a POU.
|
|
POU_FOLDER = {
|
|
"20_fb_pump.st": "function-blocks",
|
|
"21_fb_duty_selector.st": "function-blocks",
|
|
"22_fb_level_control.st": "function-blocks",
|
|
"23_fb_headroom.st": "function-blocks",
|
|
"30_prog_control.st": "programs",
|
|
"50_prog_io_mux.st": "programs",
|
|
}
|
|
|
|
# POUs added only in a given mode. Mirrors build.py's EXCLUDE.
|
|
MODE_ONLY = {
|
|
"field": {},
|
|
"sim": {"40_prog_simulation.st": "programs"},
|
|
}
|
|
|
|
POU_HEADER = re.compile(
|
|
r"^\s*(FUNCTION_BLOCK|PROGRAM|FUNCTION)\s+([A-Za-z_][A-Za-z0-9_]*)",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
# NAME [AT %LOC] : TYPE [:= INIT] ;
|
|
VAR_DECL = re.compile(
|
|
r"""^\s*(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*
|
|
(?:AT\s+(?P<loc>%[A-Za-z]+[0-9.]+)\s*)?
|
|
:\s*(?P<type>.+?)
|
|
(?:\s*:=\s*(?P<init>.+?))?
|
|
\s*;""",
|
|
re.VERBOSE,
|
|
)
|
|
|
|
ARRAY_TYPE = re.compile(
|
|
r"^ARRAY\s*\[\s*(?P<dims>[^\]]+?)\s*\]\s*OF\s+(?P<base>[A-Za-z_][A-Za-z0-9_]*)$",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
COMMENT = re.compile(r"\(\*.*?\*\)", re.DOTALL)
|
|
|
|
# Declaration sections the Editor parses separately from the body.
|
|
VAR_BLOCK = re.compile(
|
|
r"(?P<head>^[ \t]*VAR(?:_INPUT|_OUTPUT|_IN_OUT|_TEMP|_EXTERNAL)?\b[^\n]*\n)"
|
|
r"(?P<body>.*?)"
|
|
r"(?P<tail>^[ \t]*END_VAR)",
|
|
re.DOTALL | re.MULTILINE | re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def strip_comments(text):
|
|
"""Remove (* ... *) spans, preserving line count for error messages."""
|
|
return COMMENT.sub(lambda m: " " * len(m.group(0).replace("\n", "")) +
|
|
"\n" * m.group(0).count("\n"), text)
|
|
|
|
|
|
def sanitise_declarations(text):
|
|
"""Make VAR blocks digestible by the Editor's declaration parser.
|
|
|
|
The Editor (4.2.11) parses each POU's declaration sections with a
|
|
parser separate from the body compiler, and that parser rejects:
|
|
|
|
- comment-only lines inside a VAR block, e.g. "(* process image *)"
|
|
- a (* ... *) comment that wraps onto the following line
|
|
- more than one declaration on a line, e.g. "A : INT; B : INT;"
|
|
|
|
A trailing single-line comment after a declaration is fine. So:
|
|
fold multi-line comments onto one line, drop comment-only lines, and
|
|
put each declaration on its own line. Bodies are untouched, and
|
|
src/ keeps its original formatting — only the generated project is
|
|
reflowed.
|
|
|
|
Returns (text, dropped_line_count, split_count).
|
|
"""
|
|
dropped = 0
|
|
split = 0
|
|
|
|
def fix_block(m):
|
|
nonlocal dropped, split
|
|
body = m.group("body")
|
|
|
|
# Fold any comment spanning lines onto a single line.
|
|
body = COMMENT.sub(lambda c: " ".join(c.group(0).split()), body)
|
|
|
|
kept = []
|
|
for line in body.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
kept.append(line)
|
|
continue
|
|
if COMMENT.sub("", stripped).strip() == "":
|
|
dropped += 1 # comment-only line
|
|
continue
|
|
|
|
# One declaration per line. Split on ';', keeping it, and
|
|
# re-attach anything trailing the last one (a comment).
|
|
decls = re.findall(r"[^;]+;", stripped)
|
|
if len(decls) > 1:
|
|
indent = line[: len(line) - len(line.lstrip())]
|
|
trailer = stripped[sum(len(d) for d in decls):].strip()
|
|
if trailer:
|
|
decls[-1] = decls[-1] + " " + trailer
|
|
kept.extend(indent + d.strip() for d in decls)
|
|
split += len(decls) - 1
|
|
else:
|
|
kept.append(line)
|
|
return m.group("head") + "\n".join(kept) + "\n" + m.group("tail")
|
|
|
|
return VAR_BLOCK.sub(fix_block, text), dropped, split
|
|
|
|
|
|
def parse_globals(text):
|
|
"""Return the VAR_GLOBAL declarations as Editor globalVariables entries.
|
|
|
|
CONSTANT blocks are flagged: the Editor's class enum has no
|
|
'constant', so those become ordinary globals with an initial value.
|
|
"""
|
|
code = strip_comments(text)
|
|
entries = []
|
|
constants = []
|
|
|
|
for block in re.finditer(
|
|
r"VAR_GLOBAL(?P<qual>\s+CONSTANT)?(?P<body>.*?)END_VAR",
|
|
code,
|
|
re.DOTALL | re.IGNORECASE,
|
|
):
|
|
is_const = bool(block.group("qual"))
|
|
for line in block.group("body").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
m = VAR_DECL.match(line)
|
|
if not m:
|
|
sys.exit(f"GEN FAILED: cannot parse global declaration:\n {line.strip()}")
|
|
name = m.group("name")
|
|
raw_type = m.group("type").strip()
|
|
init = (m.group("init") or "").strip()
|
|
|
|
arr = ARRAY_TYPE.match(raw_type)
|
|
if arr:
|
|
base = arr.group("base").upper()
|
|
dims = [d.strip() for d in arr.group("dims").split(",")]
|
|
type_obj = {
|
|
"definition": "array",
|
|
"value": f"ARRAY[{','.join(dims)}] OF {base}",
|
|
"data": {
|
|
"baseType": {"definition": "base-type", "value": base},
|
|
"dimensions": [{"dimension": d} for d in dims],
|
|
},
|
|
}
|
|
else:
|
|
type_obj = {"definition": "base-type", "value": raw_type.upper()}
|
|
|
|
entries.append(
|
|
{
|
|
"name": name,
|
|
"type": type_obj,
|
|
"class": "global",
|
|
"location": m.group("loc") or "",
|
|
"documentation": "",
|
|
"debug": False,
|
|
"initialValue": init,
|
|
}
|
|
)
|
|
if is_const:
|
|
constants.append(name)
|
|
|
|
return entries, constants
|
|
|
|
|
|
def parse_config(text):
|
|
"""Extract tasks and program instances from the CONFIGURATION file."""
|
|
code = strip_comments(text)
|
|
|
|
tasks = []
|
|
for m in re.finditer(
|
|
r"TASK\s+(?P<name>\w+)\s*\(\s*INTERVAL\s*:=\s*(?P<interval>[^,)]+)"
|
|
r"(?:\s*,\s*PRIORITY\s*:=\s*(?P<prio>\d+))?\s*\)",
|
|
code,
|
|
re.IGNORECASE,
|
|
):
|
|
tasks.append(
|
|
{
|
|
"name": m.group("name"),
|
|
"triggering": "Cyclic",
|
|
"interval": m.group("interval").strip(),
|
|
"priority": int(m.group("prio") or 0),
|
|
}
|
|
)
|
|
|
|
instances = []
|
|
for m in re.finditer(
|
|
r"PROGRAM\s+(?P<inst>\w+)\s+WITH\s+(?P<task>\w+)\s*:\s*(?P<prog>\w+)\s*;",
|
|
code,
|
|
re.IGNORECASE,
|
|
):
|
|
instances.append(
|
|
{
|
|
"name": m.group("inst"),
|
|
"program": m.group("prog"),
|
|
"task": m.group("task"),
|
|
}
|
|
)
|
|
|
|
if not tasks:
|
|
sys.exit(f"GEN FAILED: no TASK found in {config_file}")
|
|
if not instances:
|
|
sys.exit(f"GEN FAILED: no PROGRAM instance found in {config_file}")
|
|
return tasks, instances
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Generate an OpenPLC Editor v4 project.")
|
|
ap.add_argument("--out", default="editor-project", help="output directory")
|
|
ap.add_argument("--name", default="wrps", help="project name")
|
|
ap.add_argument(
|
|
"--mode",
|
|
choices=("field", "sim"),
|
|
default="field",
|
|
help="field = control only; sim = control plus PROGRAM SIMULATION",
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
out = (ROOT / args.out).resolve()
|
|
config_file = CONFIG_FILE[args.mode]
|
|
pou_folder = {**POU_FOLDER, **MODE_ONLY[args.mode]}
|
|
|
|
missing = [n for n in [GLOBALS_FILE, config_file, *pou_folder] if not (SRC / n).is_file()]
|
|
if missing:
|
|
sys.exit("GEN FAILED: missing source file(s):\n" + "\n".join(f" src/{n}" for n in missing))
|
|
|
|
globals_entries, constants = parse_globals((SRC / GLOBALS_FILE).read_text(encoding="utf-8"))
|
|
tasks, instances = parse_config((SRC / config_file).read_text(encoding="utf-8"))
|
|
|
|
# --- POU files -----------------------------------------------------
|
|
if out.exists():
|
|
shutil.rmtree(out)
|
|
for folder in ("programs", "function-blocks", "functions"):
|
|
(out / "pous" / folder).mkdir(parents=True)
|
|
|
|
pou_names = []
|
|
total_dropped = total_split = 0
|
|
for filename, folder in pou_folder.items():
|
|
text = (SRC / filename).read_text(encoding="utf-8")
|
|
text, dropped, split = sanitise_declarations(text)
|
|
total_dropped += dropped
|
|
total_split += split
|
|
m = POU_HEADER.search(strip_comments(text))
|
|
if not m:
|
|
sys.exit(f"GEN FAILED: no POU header found in src/{filename}")
|
|
kind, name = m.group(1).upper(), m.group(2)
|
|
expected = "programs" if kind == "PROGRAM" else (
|
|
"function-blocks" if kind == "FUNCTION_BLOCK" else "functions")
|
|
if expected != folder:
|
|
sys.exit(
|
|
f"GEN FAILED: src/{filename} declares {kind} {name} but is mapped "
|
|
f"to pous/{folder}/ — fix POU_FOLDER."
|
|
)
|
|
(out / "pous" / folder / f"{name}.st").write_text(text, encoding="utf-8")
|
|
pou_names.append(f"{name} ({folder})")
|
|
|
|
# Every instance must name a program we actually emitted.
|
|
programs = {p.split(" ")[0] for p in pou_names if "(programs)" in p}
|
|
for inst in instances:
|
|
if inst["program"] not in programs:
|
|
sys.exit(
|
|
f"GEN FAILED: configuration instance '{inst['name']}' references program "
|
|
f"'{inst['program']}', which is not among the generated programs: "
|
|
f"{sorted(programs)}"
|
|
)
|
|
|
|
project = {
|
|
"meta": {"name": args.name, "type": "plc-project"},
|
|
"data": {
|
|
"dataTypes": [],
|
|
"pous": [], # the Editor discovers POUs from pous/, verified 2026-08-13
|
|
"configuration": {
|
|
"resource": {
|
|
"tasks": tasks,
|
|
"instances": instances,
|
|
"globalVariables": globals_entries,
|
|
}
|
|
},
|
|
"libraries": [],
|
|
},
|
|
}
|
|
(out / "project.json").write_text(json.dumps(project, indent=2) + "\n", encoding="utf-8")
|
|
|
|
located = sum(1 for g in globals_entries if g["location"])
|
|
print(f"OK {out}")
|
|
print(f"OK {len(pou_names)} POUs: {', '.join(pou_names)}")
|
|
print(f"OK {len(globals_entries)} globals ({located} located)")
|
|
print(
|
|
f"OK declaration sections sanitised for the Editor parser "
|
|
f"({total_dropped} comment-only lines dropped, {total_split} declarations "
|
|
f"split onto their own line; src/ unchanged)"
|
|
)
|
|
print(f"OK tasks: {[(t['name'], t['interval']) for t in tasks]}")
|
|
print(f"OK instances: {[(i['name'], i['program']) for i in instances]}")
|
|
if constants:
|
|
print(
|
|
f"WARN {len(constants)} VAR_GLOBAL CONSTANT entries emitted as ordinary "
|
|
f"globals — the Editor's class enum has no 'constant'.\n"
|
|
f" They keep their initial values and no POU writes them, so behaviour "
|
|
f"is unchanged, but they are no longer compiler-enforced read-only:\n"
|
|
f" {', '.join(constants)}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|