wrps-demo-kit/03-plc/gen_project.py
Clio Liu 13a05d0135 feat(plc): ST sources, generators and the Modbus contract
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.
2026-09-02 15:48:49 +10:00

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()