#!/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[A-Za-z_][A-Za-z0-9_]*)\s* (?:AT\s+(?P%[A-Za-z]+[0-9.]+)\s*)? :\s*(?P.+?) (?:\s*:=\s*(?P.+?))? \s*;""", re.VERBOSE, ) ARRAY_TYPE = re.compile( r"^ARRAY\s*\[\s*(?P[^\]]+?)\s*\]\s*OF\s+(?P[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^[ \t]*VAR(?:_INPUT|_OUTPUT|_IN_OUT|_TEMP|_EXTERNAL)?\b[^\n]*\n)" r"(?P.*?)" r"(?P^[ \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\s+CONSTANT)?(?P.*?)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\w+)\s*\(\s*INTERVAL\s*:=\s*(?P[^,)]+)" r"(?:\s*,\s*PRIORITY\s*:=\s*(?P\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\w+)\s+WITH\s+(?P\w+)\s*:\s*(?P\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()