#!/usr/bin/env python3 """Render built CI Server display XML back to a browser preview. python render_preview.py -> out/preview.html (all displays) python render_preview.py WRPS_FP_Pump -> just that one WHY THIS EXISTS --------------- Every preview up to now was a drawing of what I *meant* to build, made alongside the generator rather than from it. That is the same mistake as a test that checks the intent instead of the artefact, and it has already cost this project a round: a script that failed before writing its file, while a checker with hard-coded coordinates cheerfully reported the file was fine. This reads the XML that was actually generated and draws it. If a component is in the file it appears here; if it is not, it does not. It also resolves what a static picture normally cannot: live values read over Modbus, formatted through the point's own gain and display mask, so a number shows what the operator would see rather than "9999.9" visibility a component bound to a Boolean item is drawn only if that item is true; one bound to a display parameter is resolved through the genericFunctions equality that writes the parameter. Without this, every state stack renders at once and the preview is a smear. Approximate by construction: browser font metrics are not Java's, so text width is estimated. It is a faithful reading of the geometry, not a pixel-exact simulation of CI View. """ import csv import html import importlib.util import math import re import sys from pathlib import Path HERE = Path(__file__).resolve().parent OUT = HERE / "out" POINTS = HERE.parent / "modbus_points" / "scada-points.csv" QLI_GEN = HERE.parent / "modbus_points" / "gen_ciserver_qli.py" PLC_HOST, PLC_PORT, PLC_UNIT = "192.168.153.192", 502, 1 # ------------------------------------------------------------------ data def leaf_to_iec(): spec = importlib.util.spec_from_file_location("q", QLI_GEN) q = importlib.util.module_from_spec(spec) spec.loader.exec_module(q) return {"AID.WRPS.%s.%s" % v: k for k, v in q.LEAF.items()} def live_values(): """{itemName: (raw, formatted_string)} - or {} if the PLC is unreachable.""" rows = {r["iec_address"]: r for r in csv.DictReader(POINTS.open(newline="", encoding="utf-8"))} names = leaf_to_iec() try: from pymodbus.client import ModbusTcpClient c = ModbusTcpClient(PLC_HOST, port=PLC_PORT, timeout=3) if not c.connect(): return {} hr = c.read_holding_registers(address=0, count=21, device_id=PLC_UNIT).registers sp = c.read_holding_registers(address=1024, count=11, device_id=PLC_UNIT).registers sim = c.read_holding_registers(address=1044, count=4, device_id=PLC_UNIT).registers co = [int(b) for b in c.read_coils(address=0, count=15, device_id=PLC_UNIT).bits[:15]] c.close() except Exception: return {} raw = {"%%QW%d" % i: v for i, v in enumerate(hr)} raw.update({"%%MW%d" % i: v for i, v in enumerate(sp)}) raw.update({"%%MW%d" % (20 + i): v for i, v in enumerate(sim)}) raw.update({"%%QX0.%d" % i: co[i] for i in range(8)}) raw.update({"%%QX1.%d" % i: co[8 + i] for i in range(7)}) out = {} for name, iec in names.items(): if iec not in raw or iec not in rows: continue r, v = rows[iec], raw[iec] if r["data_type"] == "Boolean": out[name] = (v, str(v)) continue if v > 32767 and iec != "%QW17": v -= 65536 dec = len(r["format_mask"].split(".")[1]) if "." in r["format_mask"] else 0 out[name] = (v, "%.*f" % (dec, v * float(r["eng_gain"]))) return out # ------------------------------------------------------------------ parse def num(body, tag, default=None): m = re.search(r"<%s>(-?[\d.]+)" % (tag, tag), body) return float(m.group(1)) if m else default def attr(tag_text, name, default=None): m = re.search(r'%s="(-?[\d.]+)"' % name, tag_text) return float(m.group(1)) if m else default class Doc: def __init__(self, path): self.xml = path.read_text(encoding="utf-8") self.name = path.stem self.w = int(num(self.xml, "width", 1920) or 1920) self.h = int(num(self.xml, "height", 1080) or 1080) self.paints, self.datas = {}, {} for rid, body in re.findall(r'(.*?)', self.xml, re.S): p = re.search(r'colorSet r="(\d+)" g="(\d+)" b="(\d+)"', body) if p: self.paints[rid] = "rgb(%s,%s,%s)" % p.groups() else: self.datas[rid] = body # genericFunctions: parameter name -> (item, constant) self.funcs = {} for body in re.findall(r"(.*?)", self.xml, re.S): pname = re.search(r"([^<]+)", body) const = num(body, "numberArgument2") item = re.search(r"([^<]+)", body) if pname and const is not None and item: self.funcs[pname.group(1)] = (item.group(1), const) def paint(self, body, tag): m = re.search(r'<%s ref="(\d+)"/>' % tag, body) if m: return self.paints.get(m.group(1), "none") m = re.search(r'<%s type="paint">([^<]+)", body) if m: return m.group(1) m = re.search(r'', body) if m: d = self.datas.get(m.group(1), "") mm = re.search(r"([^<]+)", d) if mm: return mm.group(1) return None def visible(self, body, live): """Resolve a .visible binding. Unbound components are always drawn.""" refs = re.findall(r'', body) blocks = [self.datas.get(r, "") for r in refs] + [body] for blk in blocks: if ".visible" not in blk: continue p = re.search(r"([^<]+)", blk) if p: item, const = self.funcs.get(p.group(1), (None, None)) if item is None or item not in live: return None return abs(live[item][0] - const) < 0.001 i = re.search(r"([^<]+)", blk) if i: if i.group(1) not in live: return None return bool(live[i.group(1)][0]) return True def render(doc, live): """SVG for one display, components in document order.""" svg = ['' % (doc.w, doc.h, doc.w, doc.h), '' % (doc.w, doc.h)] unknown = 0 pattern = (r'<(rectangle|ellipse|line|text|number|numberField|dataBar|button|microTrend)' r'( type="componentData"[^>]*)>(.*?)') for kind, head, body in re.findall(pattern, doc.xml, re.S): vis = doc.visible(body, live) if vis is False: continue if vis is None: unknown += 1 op = ' opacity="0.35"' if vis is None else "" # geometry: child elements, or attributes on the tag (numberField/dataBar/button) cx = num(body, "x", attr(head, "x")) cy = num(body, "y", attr(head, "y")) if cx is None or cy is None: continue t = num(body, "top", attr(head, "top", 0)) or 0 b = num(body, "bottom", attr(head, "bottom", 0)) or 0 l = num(body, "left", attr(head, "left", 0)) or 0 r = num(body, "right", attr(head, "right", 0)) or 0 x, y, w, h = cx - l, cy - t, l + r, t + b if kind == "rectangle": svg.append('' % (x, y, w, h, doc.paint(body, "fillPaint"), doc.paint(body, "strokePaint"), num(body, "width", 1) or attr(body, "width", 1) or 1, op)) elif kind == "ellipse": svg.append('' % (cx, cy, l, t, doc.paint(body, "fillPaint"), doc.paint(body, "strokePaint"), op)) elif kind == "line": pts = re.findall(r"(-?[\d.]+)(-?[\d.]+)", body) if len(pts) >= 2: svg.append('' % (" ".join("%s,%s" % p for p in pts), doc.paint(body, "strokePaint"), float(re.search(r'stroke" width="([\d.]+)"', body).group(1)) if re.search(r'stroke" width="([\d.]+)"', body) else 2, op)) else: svg.append('' % (x, y, x + w, y + h, doc.paint(body, "strokePaint"), op)) elif kind == "text": v = re.search(r"(.*?)", body, re.S) f = re.search(r'name="([^"]+)" bold="(\w+)"[^>]*size="(\d+)"', body) fam, bold, size = (f.group(1), f.group(2), int(f.group(3))) if f else ("Segoe UI", "false", 13) svg.append('%s' % (cx, cy + size * 0.35, fam, size, "700" if bold == "true" else "400", doc.paint(body, "fillPaint"), op, html.escape(v.group(1) if v else ""))) elif kind == "number": item = doc.bound_item(body) mask = re.search(r"([^<]*)", body) shown = live.get(item, (None, mask.group(1) if mask else "?"))[1] f = re.search(r'size="(\d+)"', body) size = int(f.group(1)) if f else 20 svg.append('%s' % (cx, cy + size * 0.35, size, doc.paint(body, "fillPaint"), op, html.escape(str(shown)))) elif kind == "numberField": item = doc.bound_item(body) shown = live.get(item, (None, ""))[1] svg.append('' % (x, y, w, h, op)) svg.append('%s' % (x + 8, cy + 5, op, html.escape(str(shown)))) elif kind == "dataBar": item = doc.bound_item(body) lo = num(body, "lowLimit", 0) or 0 hi = num(body, "highLimit", 100) or 100 val = live.get(item, (lo, ""))[0] try: pct = max(0.0, min(1.0, (float(live[item][1]) - lo) / (hi - lo))) except Exception: pct = 0.0 svg.append('' % (x, y, w, h, doc.paint(body, "background"), op)) svg.append('' % (x, y + h * (1 - pct), w, h * pct, doc.paint(body, "foreground"), op)) elif kind == "microTrend": # the trace itself is CI Server's; what the preview has to show is # the FOOTPRINT, so a trend that lands on a pipe is visible here # rather than after deployment item = doc.bound_item(body) hi = num(body, "value", 100) or 100 # scaleMax[0] svg.append('' % (x, y, w, h, op)) try: pct = max(0.0, min(1.0, float(live[item][1]) / hi)) except Exception: pct = 0.5 svg.append('' % (" ".join("%.1f,%.1f" % (x + w * k / 24.0, y + h * (1 - pct * (0.55 + 0.45 * math.sin(k / 2.4)))) for k in range(25)), op)) svg.append('%s' % (x + 4, y + 11, op, html.escape((item or "").replace("AID.WRPS.", "")))) elif kind == "button": lab = re.search(r"", body) fg = re.search(r'foregroundColor type="colorSet" r="(\d+)" g="(\d+)" b="(\d+)"', body) bg = re.search(r'backgroundColor type="colorSet" r="(\d+)" g="(\d+)" b="(\d+)"', body) svg.append('' % (x, y, w, h, "rgb(%s,%s,%s)" % bg.groups() if bg else "#fff", op)) svg.append('%s' % (cx, cy + 5, "rgb(%s,%s,%s)" % fg.groups() if fg else "#0e1621", op, html.escape(lab.group(1) if lab else ""))) svg.append("") return "\n".join(svg), unknown def main(): want = sys.argv[1:] files = sorted(OUT.glob("WRPS_*.xml")) if want: files = [f for f in files if f.stem in want] if not files: sys.exit("nothing to render in %s" % OUT) live = live_values() parts = ['WRPS displays — rendered from the built XML', '
', "

Rendered from the generated XML in out/

", '

%s. Components are drawn only if the file contains them; ' "values and visibility resolved against the live PLC.

" % ("Live values read from the PLC" if live else "PLC unreachable - numbers show their format mask, state-bound shapes are dimmed")] for f in files: doc = Doc(f) svg, unknown = render(doc, live) note = " %d component(s) whose visibility could not be resolved, shown dimmed" % unknown if unknown else "" parts.append("

%s  %d x %d%s

" % (doc.name, doc.w, doc.h, note)) parts.append('
%s
' % svg) print("rendered %-22s %d x %d" % (doc.name, doc.w, doc.h)) parts.append("
") dest = OUT / "preview.html" dest.write_text("\n".join(parts), encoding="utf-8") print("wrote %s" % dest) if __name__ == "__main__": main()