wrps-demo-kit/04-scada/hmi/render_preview.py
Clio Liu 947f632d7f feat(scada): CI Server tag database, historian, displays
The old 05-scada/, restructured around the distinction its README never
drew: configuration is deployed with dssqld, displays are deployed by
file copy. Conflating the two is what made the folder confusing.

  modbus_points/          the tag database - named for the protocol,
                          since CI Server configures others differently
  modbus_points/historian/  3 groups, 49 bindings - HAND-MADE, no
                          generator, and drifted from the server
  hmi/                    displays and their generator
  ciserver-backup-2026-08/  outdated exports, evidence only, never import
  QUICKLOAD.md            dssqld export/import, the 5 classes, the import
                          order, and why an item import kills every display
  README.md               the chain end to end, and the not-updating triage

Verified during the move - the whole chain is reproducible:
  scada-points.csv and all three .qli regenerate byte-identically
  all six displays build clean

Removed the K offset machinery from build_display.py, item-ids.meta.json
and DEPLOY.md. K was a consistency check on measured ids, not a source of
them, and diagnosing a dead screen by arithmetic is wasted effort when
validating the display in CI Server's Editor Module fixes it outright.
The guidance now leads with that one action.

Recorded, not fixed: the repo's historian config disagrees with the
2026-08 CI Server export - WRPS_THIRTY_SEC and a FIVE_SECONDS group exist
on the server and not here. cicore1 was unreachable during this audit, so
which is correct is unknown.

Not carried across: __pycache__, out/*.xml, and the top-level
WRPS_Overview.xml that was tracked despite .gitignore declaring the
display XMLs to be build output.
2026-09-02 17:16:18 +10:00

319 lines
16 KiB
Python

#!/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.]+)</%s>" % (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'<intRef id="(\d+)">(.*?)</intRef>', 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"<genericFunctions type=\"componentData\">(.*?)</genericFunctions>",
self.xml, re.S):
pname = re.search(r"<name>([^<]+)</name>", body)
const = num(body, "numberArgument2")
item = re.search(r"<itemName>([^<]+)</itemName>", 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"><paint type="colorSet" r="(\d+)" g="(\d+)" b="(\d+)"' % tag, body)
return "rgb(%s,%s,%s)" % m.groups() if m else "none"
def bound_item(self, body):
m = re.search(r"<itemName>([^<]+)</itemName>", body)
if m:
return m.group(1)
m = re.search(r'<data ref="(\d+)"/>', body)
if m:
d = self.datas.get(m.group(1), "")
mm = re.search(r"<itemName>([^<]+)</itemName>", 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'<data ref="(\d+)"/>', 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"<parameter>([^<]+)</parameter>", 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"<itemName>([^<]+)</itemName>", 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 = ['<svg viewBox="0 0 %d %d" width="%d" height="%d" xmlns="http://www.w3.org/2000/svg">'
% (doc.w, doc.h, doc.w, doc.h),
'<rect width="%d" height="%d" fill="rgb(237,240,244)"/>' % (doc.w, doc.h)]
unknown = 0
pattern = (r'<(rectangle|ellipse|line|text|number|numberField|dataBar|button|microTrend)'
r'( type="componentData"[^>]*)>(.*?)</\1>')
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('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" stroke="%s" stroke-width="%.1f"%s/>'
% (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('<ellipse cx="%.1f" cy="%.1f" rx="%.1f" ry="%.1f" fill="%s" stroke="%s" stroke-width="2"%s/>'
% (cx, cy, l, t, doc.paint(body, "fillPaint"), doc.paint(body, "strokePaint"), op))
elif kind == "line":
pts = re.findall(r"<point><x>(-?[\d.]+)</x><y>(-?[\d.]+)</y></point>", body)
if len(pts) >= 2:
svg.append('<polyline points="%s" fill="none" stroke="%s" stroke-width="%.1f"%s/>'
% (" ".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('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="2"%s/>'
% (x, y, x + w, y + h, doc.paint(body, "strokePaint"), op))
elif kind == "text":
v = re.search(r"<value>(.*?)</value>", 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('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="%s" font-size="%d" '
'font-weight="%s" fill="%s"%s>%s</text>'
% (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"<format>([^<]*)</format>", 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('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="Consolas,monospace" '
'font-size="%d" font-weight="700" fill="%s"%s>%s</text>'
% (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('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#fff" stroke="rgb(11,125,168)"%s/>'
% (x, y, w, h, op))
svg.append('<text x="%.1f" y="%.1f" font-family="Consolas,monospace" font-size="14" fill="#0e1621"%s>%s</text>'
% (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('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"%s/>'
% (x, y, w, h, doc.paint(body, "background"), op))
svg.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"%s/>'
% (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('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="rgb(220,220,220)" '
'stroke="rgb(195,204,216)"%s/>' % (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('<polyline points="%s" fill="none" stroke="rgb(11,125,168)" stroke-width="1.5"%s/>'
% (" ".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('<text x="%.1f" y="%.1f" font-family="Consolas,monospace" font-size="9" '
'fill="rgb(70,84,104)"%s>%s</text>'
% (x + 4, y + 11, op, html.escape((item or "").replace("AID.WRPS.", ""))))
elif kind == "button":
lab = re.search(r"<label>([^<]*)</label>", 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('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" stroke="rgb(195,204,216)"%s/>'
% (x, y, w, h, "rgb(%s,%s,%s)" % bg.groups() if bg else "#fff", op))
svg.append('<text x="%.1f" y="%.1f" text-anchor="middle" font-family="Segoe UI" font-size="15" '
'font-weight="700" fill="%s"%s>%s</text>'
% (cx, cy + 5, "rgb(%s,%s,%s)" % fg.groups() if fg else "#0e1621", op,
html.escape(lab.group(1) if lab else "")))
svg.append("</svg>")
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 = ['<!doctype html><meta charset="utf-8"><title>WRPS displays — rendered from the built XML</title>',
'<style>body{margin:0;background:#eef1f5;font-family:"Segoe UI",sans-serif;color:#0e1621}'
'.w{padding:20px}h1{font-size:15px;margin:0 0 2px}p.s{font-size:12px;color:#465468;margin:0 0 16px}'
'h2{font-size:13px;margin:22px 0 6px}.box{background:#fff;border:1px solid #c3ccd8;overflow:auto;display:inline-block}'
'</style><div class="w">',
"<h1>Rendered from the generated XML in <code>out/</code></h1>",
'<p class="s">%s. Components are drawn only if the file contains them; '
"values and visibility resolved against the live PLC.</p>"
% ("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 = " <span style='color:#b45309'>%d component(s) whose visibility could not be resolved, shown dimmed</span>" % unknown if unknown else ""
parts.append("<h2>%s &nbsp;<span style='font-weight:400;color:#465468'>%d x %d</span>%s</h2>"
% (doc.name, doc.w, doc.h, note))
parts.append('<div class="box">%s</div>' % svg)
print("rendered %-22s %d x %d" % (doc.name, doc.w, doc.h))
parts.append("</div>")
dest = OUT / "preview.html"
dest.write_text("\n".join(parts), encoding="utf-8")
print("wrote %s" % dest)
if __name__ == "__main__":
main()