#!/usr/bin/env python3 """Validate an ACC PLC Simulator ladder program (.json) before importing it. Checks the rules used by the simulator's own import and scan code (acc-plc-simulator.html v1.122), verified by running that engine. Usage: python3 acc-validate-program.py my-program.json """ import json, re, sys N256 = r'([1-9]\d?|1\d\d|2[0-4]\d|25[0-6])' N16 = r'([1-9]|1[0-6])' BIT_ADDR = re.compile(rf'^(X{N16}|Y{N16}|C{N256}|T{N256}(/(DN|EN|TT))?|CT{N256}(/DN)?)$') OUT_ADDR = re.compile(rf'^(Y{N16}|C{N256})$') TIMER = re.compile(rf'^T{N256}$') COUNTER = re.compile(rf'^CT{N256}$') INT_REG = re.compile(r'^(AX[1-8]|AY[1-4])$') AY_REG = re.compile(r'^AY[1-4]$') ANY_ADDR = re.compile(rf'^(X{N16}|Y{N16}|C{N256}|T{N256}(/(DN|EN|TT))?|CT{N256}(/DN)?|AX[1-8]|AY[1-4])$') _C_NUM = re.compile(r'^C(\d+)$') def out_of_range_c(a): """C0 and C257+ read and write, but never show on the I/O panel.""" m = _C_NUM.match(a.upper()) return bool(m) and not (1 <= int(m.group(1)) <= 256) errors, warnings, ids, rung_ids = [], [], [], [] ctu, ctd = set(), set() def err(w, m): errors.append(f"{w}: {m}") def warn(w, m): warnings.append(f"{w}: {m}") def is_num(v): return isinstance(v, (int, float)) and not isinstance(v, bool) def common(e, w): if not isinstance(e.get("id"), int): err(w, "every element needs an integer 'id'") else: ids.append(e["id"]) if e.get("comment"): warn(w, "element 'comment' is the legacy format; put address comments in the top-level 'comments' map") def check_input(e, w): """Contacts and compares may sit before the output or inside branches.""" common(e, w) k, t, a = e.get("kind"), e.get("type"), str(e.get("addr", "")) if k == "contact": if t not in ("XIC", "XIO"): err(w, f"contact type must be XIC or XIO, not {t!r}") if a.upper().startswith(("AX", "AY")): err(w, f"contacts cannot read analog registers ({a}); use a compare element") elif out_of_range_c(a): warn(w, f"{a} is outside the documented C1-C256 range: it reads and writes, but never " f"appears on the I/O panel, so the bit is invisible while debugging") elif not BIT_ADDR.match(a): err(w, f"invalid contact address {a!r}") elif k == "compare": if t not in ("GRT", "LES", "GEQ", "LEQ", "EQU", "NEQ"): err(w, f"compare type must be GRT/LES/GEQ/LEQ/EQU/NEQ, not {t!r}") if not INT_REG.match(a): err(w, f"compare address must be AX1-AX8 or AY1-AY4 (got {a!r})") if isinstance(e.get("cmp"), str): err(w, "'cmp' must be a number, not a string (EQU/NEQ never match a string comparand)") elif not is_num(e.get("cmp")): err(w, "compare needs a numeric 'cmp' (the comparand)") elif not 0 <= e["cmp"] <= 4095: warn(w, f"comparand {e['cmp']} is outside 0-4095, so the compare is always true or always false") elif k == "parallel": err(w, "parallel group nested inside a branch: it evaluates correctly but the rung is sized from " "top-level groups only, so the nested rows draw over the rungs below. Flatten it into more branches") else: err(w, f"only contacts and compares may be used as inputs (found kind={k!r})") def check_output(e, w): common(e, w) k, t, a = e.get("kind"), e.get("type"), str(e.get("addr", "")) if k == "output": if t not in ("OTE", "OTL", "OTU"): err(w, f"output type must be OTE/OTL/OTU, not {t!r}") if a.upper().startswith("X"): err(w, f"coil writes input {a} — a connected scene overwrites every X each scan, so the rung does nothing; write a Y or C instead") elif a.upper().startswith(("AX", "AY")): err(w, f"coils cannot write analog register {a}; use a MOV block") elif out_of_range_c(a): warn(w, f"{a} is outside the documented C1-C256 range: it reads and writes, but never " f"appears on the I/O panel, so the bit is invisible while debugging") elif not OUT_ADDR.match(a): err(w, f"coils may only write Y1-Y16 or C1-C256 (got {a!r})") elif k == "block": if t in ("TON", "TOF"): if not TIMER.match(a): err(w, f"timer address must be T1-T256 (got {a!r})") if not isinstance(e.get("pre"), int) or e["pre"] <= 0: err(w, "timer needs a positive integer 'pre' in milliseconds") elif t in ("CTU", "CTD"): if not COUNTER.match(a): err(w, f"counter address must be CT1-CT256 (got {a!r})") if not isinstance(e.get("pre"), int) or e["pre"] < 0: err(w, "counter needs a non-negative integer 'pre' (the target count)") (ctu if t == "CTU" else ctd).add(a) elif t == "RES": if not (TIMER.match(a) or COUNTER.match(a)): err(w, f"RES address must be a timer (T1) or counter (CT1), got {a!r}") elif t == "MOV": if not AY_REG.match(a): err(w, f"MOV destination must be AY1-AY4 (got {a!r})") src = str(e.get("src", "")) if "src" not in e: err(w, "MOV needs a 'src' string: a constant such as \"2048\" or a register such as \"AX1\"") elif not INT_REG.match(src.upper()): try: v = float(src) if not 0 <= v <= 4095: warn(w, f"MOV source {src} is clamped to 0-4095") except ValueError: err(w, f"MOV src {src!r} is not a number, AX1-AX8 or AY1-AY4 (it would move 0)") else: err(w, f"block type must be TON/TOF/CTU/CTD/RES/MOV, not {t!r}") else: err(w, f"the last element must be an output coil or a block (found kind={k!r})") def check_rung(r, n, w): if not isinstance(r.get("id"), int): err(w, "rung needs an integer 'id'") else: rung_ids.append(r["id"]) if r.get("rn") != n: warn(w, f"'rn' is {r.get('rn')!r}; the simulator renumbers on import, but {n} keeps the file readable") els = r.get("elements") if not isinstance(els, list): err(w, "missing 'elements' array (the import would fail silently)"); return if not els: warn(w, "empty rung: it scans harmlessly, but its comment is NOT displayed; remove it or add logic") return kinds = [e.get("kind") for e in els] if not any(k in ("output", "block") for k in kinds): err(w, "rung has no output: it needs at least one coil or block") if kinds[-1] not in ("output", "block"): err(w, "rung ends with a contact or compare, which drives nothing; " "the last element must be a coil or block") for j, e in enumerate(els[:-1]): ew = f"{w} element[{j}]" if e.get("kind") in ("output", "block"): # Legal and used by the shipped example programs: coils in series all fire, # and any contact after one gates only the outputs that follow it. check_output(e, ew) continue if e.get("kind") == "parallel": common(e, ew) br = e.get("branches", []) if len(br) == 1: warn(ew, "single-branch parallel group: it loads and behaves as that branch, but the editor " "expects 2+. Use a plain series contact instead") elif not br: err(ew, "parallel group has no branches") for b, branch in enumerate(br): if not branch: err(ew, f"branch {b} is empty") for c, be in enumerate(branch): check_input(be, f"{ew} branch{b}[{c}]") else: check_input(e, ew) check_output(els[-1], f"{w} element[{len(els)-1}]") def main(path): with open(path, encoding="utf-8") as f: d = json.load(f) if isinstance(d, list): warn("file", "bare rung array accepted by the importer; the wrapped v2 object is preferred") prog, d = d, {"program": d} else: prog = d.get("program") if not isinstance(prog, list): err("file", "missing top-level 'program' array"); prog = [] if d.get("version") != 2: warn("file", "use \"version\": 2 (current export format)") for n, r in enumerate(prog): check_rung(r, n, f"rung {n:04d}") cm = d.get("comments") if cm is None: warn("file", "no top-level 'comments' map; addresses will show no comments") elif not isinstance(cm, dict): err("file", "'comments' must be an object of address -> text") else: for k, v in cm.items(): if k != k.upper(): err("comments", f"key {k!r} must be upper case") elif not ANY_ADDR.match(k) and not out_of_range_c(k): err("comments", f"key {k!r} must be an address such as X1, C101, T1, T1/DN, AX1") if not isinstance(v, str): err("comments", f"comment for {k} must be a string") for a in sorted(ctd - ctu): warn("file", f"{a} has CTD but no CTU; CTD cannot count below 0, so it will never change") rdup = sorted({i for i in rung_ids if rung_ids.count(i) > 1}) if rdup: err("file", f"duplicate rung ids: {rdup}") dup = sorted({i for i in ids if ids.count(i) > 1}) if dup: err("file", f"duplicate element ids: {dup}") for m in warnings: print(" warning -", m) if errors: print(f"FAILED - {len(errors)} problem(s):") for m in errors: print(" -", m) sys.exit(1) print(f"OK - {len(prog)} rungs, {len(ids)} elements, ids unique" + (f", {len(warnings)} warning(s)" if warnings else "")) if __name__ == "__main__": if len(sys.argv) != 2: sys.exit("Usage: python3 acc-validate-program.py ") main(sys.argv[1])