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.
416 lines
18 KiB
Python
416 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Emit CI Server .qli imports for the WRPS points.
|
|
|
|
python gen_scada_points.py # first: refresh scada-points.csv
|
|
python gen_ciserver_qli.py
|
|
|
|
Writes, alongside this script:
|
|
|
|
wrps_section_df.qli @SECTION_DF - the AID.WRPS.* sections
|
|
wrps_modbus_point_df.qli @MODBUS_POINT_DF - the Modbus point definitions
|
|
wrps_item_df.qli @ITEM_DF - the items in those sections
|
|
|
|
Import them in that order: CI Server derives its hierarchy from the dots
|
|
in a name, and a section must exist before an item can be created in it.
|
|
|
|
The chain is ST sources -> 03-plc/register-map.csv -> scada-points.csv ->
|
|
these files, so a register change propagates by re-running the three
|
|
scripts rather than by hand-editing anything.
|
|
|
|
Field layouts and every constant here were copied from the user's own
|
|
exports in 99-reference/ciserver-qli-exports/ (section_df.qli,
|
|
modbus_point_df.qli, item_df.qli), not invented.
|
|
|
|
NSIDs
|
|
-----
|
|
The NSID space is shared by sections and items. In the reference
|
|
exports items occupy 3-166 and sections 1-170, so 171 is the first free
|
|
id: sections take 171-176 and items 177 onward (--nsid-base).
|
|
|
|
AID (169) and AID.WRPS (170) already exist, so this script emits only
|
|
their six children and hangs them off 170 (--parent-nsid). Pass
|
|
--include-root to emit AID and AID.WRPS as well, for a system that does
|
|
not have them yet.
|
|
|
|
THE ONE THING TO VERIFY ON A SMALL TEST IMPORT
|
|
----------------------------------------------
|
|
**Address base** (--address-base, default 1). The reference export maps
|
|
point AI_01 to IO_ADDRESS "RI:01". Whether CI Server's 01 means
|
|
protocol address 0 or 1 cannot be told from the file. Default here is
|
|
1-based, i.e. IO_ADDRESS = modbus address + 1, so holding register 0
|
|
(%QW0, wet well level) becomes "RO:01". If the level shows up where the
|
|
inflow should be, it is 0-based: re-run with --address-base 0.
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
POINTS = HERE / "scada-points.csv"
|
|
|
|
STATION = "WRPS_PLC"
|
|
ROOT = "AID.WRPS"
|
|
|
|
# IO_ADDRESS prefixes, from the reference export:
|
|
# RI = register input (FC04) RO = register output (FC03/06)
|
|
# DI = digital input (FC02) DO = digital output (FC01)
|
|
PREFIX = {"FC01": "DO", "FC02": "DI", "FC03": "RO", "FC03/FC06": "RO", "FC04": "RI"}
|
|
|
|
# Section per poll group; PS_PUBLISHED and PS_STATUS_BITS are split by
|
|
# equipment so each pump gets its own folder.
|
|
GROUP_SECTION = {"PS_SETPOINTS": "SP", "PS_SIM_CONTROL": "SIM"}
|
|
|
|
SECTION_ORDER = ["STN", "PU301", "PU302", "PU303", "SP", "SIM"]
|
|
|
|
SECTION_DESC = {
|
|
"STN": "Waterloo Road PS - station wide measurements and status",
|
|
"PU301": "Pump PU-301",
|
|
"PU302": "Pump PU-302",
|
|
"PU303": "Pump PU-303",
|
|
"SP": "Operator setpoints and commands",
|
|
"SIM": "Simulation control - simulation build only",
|
|
}
|
|
|
|
SECTION_FIELDS = [
|
|
"NAME", "SECTION_PATH", "SECTION_NAME", "BLOCKED", "PARENT_BLOCKED",
|
|
"ALARM_INHIBIT", "PARENT_ALARM_INHIBIT", "OPC_VISIBLE", "PARENT_OPC_VISIBLE", "NSID", "PARENT_NSID",
|
|
"DESCRIPTION", "CREATED_BY",
|
|
]
|
|
|
|
# Short, stable leaf names. Keyed on IEC address so they never drift
|
|
# with a description edit.
|
|
LEAF = {
|
|
"%QX0.0": ("PU301", "RUN_CMD"), "%QX0.1": ("PU302", "RUN_CMD"),
|
|
"%QX0.2": ("PU303", "RUN_CMD"), "%QX0.3": ("PU301", "RUNNING"),
|
|
"%QX0.4": ("PU302", "RUNNING"), "%QX0.5": ("PU303", "RUNNING"),
|
|
"%QX0.6": ("PU301", "AVAILABLE"), "%QX0.7": ("PU302", "AVAILABLE"),
|
|
"%QX1.0": ("PU303", "AVAILABLE"), "%QX1.1": ("STN", "IN_AUTO"),
|
|
"%QX1.2": ("STN", "HIGH_LEVEL"), "%QX1.3": ("STN", "SPILL_ACTIVE"),
|
|
"%QX1.4": ("PU301", "TRIPPED"), "%QX1.5": ("PU302", "TRIPPED"),
|
|
"%QX1.6": ("PU303", "TRIPPED"),
|
|
"%QW0": ("STN", "LEVEL"), "%QW1": ("STN", "INFLOW"),
|
|
"%QW2": ("STN", "DISCHARGE"), "%QW3": ("STN", "PUMPS_RUNNING"),
|
|
"%QW4": ("STN", "SPEED"), "%QW5": ("STN", "TIME_TO_SPILL"),
|
|
"%QW6": ("STN", "TIME_TO_LSHH"), "%QW7": ("STN", "NET_ACCUM"),
|
|
"%QW8": ("PU301", "RUN_HOURS"), "%QW9": ("PU302", "RUN_HOURS"),
|
|
"%QW10": ("PU303", "RUN_HOURS"), "%QW11": ("STN", "VOL_TO_SPILL"),
|
|
"%QW12": ("STN", "STATE"), "%QW13": ("PU301", "STATE"),
|
|
"%QW14": ("PU302", "STATE"), "%QW15": ("PU303", "STATE"),
|
|
"%QW16": ("STN", "DUTY_PUMP"), "%QW17": ("STN", "ALARM_WORD"),
|
|
"%QW20": ("STN", "CMD_ACK"),
|
|
"%MW0": ("SP", "MODE"), "%MW1": ("SP", "CMD_WORD"),
|
|
"%MW2": ("SP", "CMD_PARAM"), "%MW3": ("SP", "LEVEL_SP"),
|
|
"%MW4": ("SP", "START_DUTY"), "%MW5": ("SP", "START_P2"),
|
|
"%MW6": ("SP", "START_P3"), "%MW7": ("SP", "STOP_ALL"),
|
|
"%MW8": ("SP", "HIGH_ALARM"), "%MW9": ("SP", "MIN_SPEED"),
|
|
"%MW10": ("SP", "SERVICE_HRS"),
|
|
"%MW20": ("SIM", "INFLOW"), "%MW21": ("SIM", "SCENARIO"),
|
|
"%MW22": ("SIM", "RESET"), "%MW23": ("SIM", "TIME_SCALE"),
|
|
}
|
|
|
|
|
|
def q(v):
|
|
"""Quote a .qli value the way the reference exports do."""
|
|
return '"%s"' % ("" if v is None else str(v))
|
|
|
|
|
|
def wrap(values):
|
|
"""Split into the reference exports' line shape: 5, then 6 per line.
|
|
|
|
Both reference files wrap this way, in the @FIELDS header and in
|
|
every record. Reproduced exactly rather than assuming the importer
|
|
tolerates a different grouping.
|
|
"""
|
|
out = [list(values[:5])]
|
|
rest = list(values[5:])
|
|
for i in range(0, len(rest), 6):
|
|
out.append(rest[i:i + 6])
|
|
return out
|
|
|
|
|
|
def record(rows):
|
|
"""Format one record, backslash continuations between lines."""
|
|
lines = [",".join(str(v) for v in chunk) for chunk in wrap(rows)]
|
|
return (",\\\n".join(lines)) + "\n"
|
|
|
|
|
|
def header(version, fields, tag):
|
|
lines = ["", "@LANGUAGE", "ENGLISH", "", "", "@VERSION", version, "", "", "!" + "=" * 130, "", "@FIELDS"]
|
|
lines += [",".join(chunk) for chunk in wrap(fields)]
|
|
lines += ["", tag]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
POINT_FIELDS = [
|
|
"NAME", "STATION", "POINT", "DESCRIPTION", "IO_ADDRESS",
|
|
"EXTERNAL_RELATION", "SCAN_TYPE", "CONV_TYPE", "DELTA_LIMIT", "MAX_INSENS", "OFFSET",
|
|
"PHYS_LOW", "PHYS_HIGH", "STEP", "INVERS", "HAS_SIGN", "OVERFL_DET",
|
|
"SWAP_BYTES", "SWAP_WORDS", "ELEC_LOW", "ELEC_HIGH", "TMO_NONE", "TMO_BOTH",
|
|
"AVE_UPD_INTERVAL", "NO_ZERO", "BURST_LIMIT", "BITS", "CHARS", "DIGITS",
|
|
"FLOAT_TYPE", "TIME_ZONE", "TIME_REPRES", "WLS_VAL_TYPE",
|
|
]
|
|
|
|
ITEM_FIELDS = [
|
|
"NAME", "NSID", "PARENT_NSID", "SECTION_PATH", "DESCRIPTION",
|
|
"DEADBAND", "LOW_LIMIT", "HIGH_LIMIT", "LOW_LOW_LIMIT", "HIGH_HIGH_LIMIT", "T_VALUE",
|
|
"P_VALUE", "I_VALUE", "TREND_UP_LIMIT", "TREND_LOW_LIMIT", "SCALE_HIGH_LIMIT", "SCALE_LOW_LIMIT",
|
|
"DUMMY2", "COMMENT_1", "COMMENT_2", "VALUE_FORMAT", "POSITIONED", "LONGITUDE",
|
|
"LATITUDE", "ALARMING", "DIAG", "NAME_IN_ITM_TAB", "STORAGE", "AUDIT_INFO",
|
|
"FO_ITEM", "BLOCKED", "PARENT_BLOCKED", "ALARM_INHIBIT", "PARENT_ALARM_INHIBIT", "OPC_VISIBLE",
|
|
"PARENT_OPC_VISIBLE", "OPC_READ", "OPC_WRITE", "OPC_ALARM_DETECTION", "HAS_SUB", "STRING_LENGTH",
|
|
"DELAY", "REPEAT", "ID_GROUP", "ID_NUMBER", "ITEM_REP", "ITEM_TYPE",
|
|
"ITEM_SPECIAL", "ACKN_TYPE", "ALARM_GROUP", "FRONT_END_NODE", "DISTR_TYPE", "INSTALL",
|
|
"UNIT", "TAG", "LIMIT_CLAMP", "OUT_OF_RANGE", "COL_GROUP", "STATION",
|
|
"POINT", "FO_GROUP", "ITEM_STAT_1", "ITEM_STAT_2", "ITEM_STAT_3", "ITEM_STAT_4",
|
|
"ITEM_STAT_5", "ITEM_STAT_6", "ALARM_STATE_1", "ALARM_STATE_2", "ALARM_STATE_3", "ALARM_STATE_4",
|
|
"ALARM_STATE_5", "ALARM_STATE_6", "PRIORITY_1", "PRIORITY_2", "PRIORITY_3", "PRIORITY_4",
|
|
"PRIORITY_5", "PRIORITY_6", "ALARM_TEXT_1", "ALARM_TEXT_2", "ALARM_TEXT_3", "ALARM_TEXT_4",
|
|
"ALARM_TEXT_5", "ALARM_TEXT_6", "ALARM_COLOR_1", "ALARM_COLOR_2", "ALARM_COLOR_3", "ALARM_COLOR_4",
|
|
"ALARM_COLOR_5", "ALARM_COLOR_6", "MNEMONIC_1", "MNEMONIC_2", "MNEMONIC_3", "MNEMONIC_4",
|
|
"MNEMONIC_5", "MNEMONIC_6", "AOI_1", "AOI_2", "AOI_3", "AOI_4",
|
|
"AOI_5", "AOI_6", "AOI_7", "AOI_8", "AOI_9", "AOI_10",
|
|
"AOI_11", "AOI_12", "AOI_13", "AOI_14", "AOI_15", "AOI_16",
|
|
"ENG_UNIT", "PROCESS_LIST", "POINT_NAME", "OPC_AE_STATION_NAME", "OPC_EVENT_SOURCE", "OPC_EVENT_SOURCE_NAME",
|
|
"CREATED_BY", "SHELVE_ENABLED_1", "SHELVE_ENABLED_2", "SHELVE_ENABLED_3", "SHELVE_ENABLED_4", "SHELVE_ENABLED_5",
|
|
"SHELVE_ENABLED_6", "AGG_INTERVAL",
|
|
]
|
|
|
|
|
|
def build_section(name, parent_path, leaf, nsid, parent_nsid, description):
|
|
"""One @SECTION_DF record, shaped like the reference export.
|
|
|
|
PARENT_OPC_VISIBLE is 1 for a root section and 0 for a child, which
|
|
is what the reference shows for AID (root) versus AID.WRPS (child).
|
|
"""
|
|
return record([
|
|
q(name), q(parent_path), q(leaf), 0, 0,
|
|
0, 0, 0, 1 if parent_path == "" else 0, nsid, parent_nsid,
|
|
q(description), q("unknown"),
|
|
])
|
|
|
|
|
|
def trimmed_span(gain, signed):
|
|
"""The ELEC range to map from, trimmed so PHYS lands on exact decimals.
|
|
|
|
CI Server stores the conversion as two endpoints, not as a gain, so
|
|
it recovers the gain by dividing. With a gain of 1/60 the full-scale
|
|
endpoint 32767/60 = 546.11666... has to be written rounded, and the
|
|
recovered gain is then slightly off: setpoint 4200 came back as
|
|
70.00003333333336 rather than 70.
|
|
|
|
So when the gain is 1/n, trim the span to the nearest multiple of n
|
|
inside it - 32760 instead of 32767, giving PHYS exactly +/-546 and a
|
|
gain CI Server recovers exactly. Nothing is lost: the trimmed 7
|
|
counts are 0.4 mm of a level register that tops out at 7000.
|
|
|
|
Gains that already terminate (0.1, 0.2, 0.36) keep the full span.
|
|
"""
|
|
lo, hi = (-32768, 32767) if signed else (0, 65535)
|
|
inv = 1.0 / gain
|
|
n = int(round(inv))
|
|
if n > 1 and abs(inv - n) < 1e-9:
|
|
lo, hi = -((-lo) // n) * n, (hi // n) * n
|
|
return lo, hi
|
|
|
|
|
|
def build_point(row, io_addr):
|
|
digital = row["data_type"] == "Boolean"
|
|
writable = row["access"] == "Read/Write"
|
|
gain = float(row["eng_gain"])
|
|
signed = row["data_type"] == "Signed 16-bit"
|
|
|
|
if digital:
|
|
conv, bits = "Digital", 16
|
|
elec_lo, elec_hi, phys_lo, phys_hi = 0, 100, 0, 100
|
|
else:
|
|
conv, bits = "Linear", 16
|
|
# Full-scale linear mapping. Exact, and invents no plant range;
|
|
# display ranges and alarm limits are set per item in CI Server.
|
|
# The engineering unit is carried entirely by the gain (see UNITS
|
|
# in gen_scada_points.py), so PHYS is ELEC scaled by it - which is
|
|
# why the offset must stay zero on both ends.
|
|
elec_lo, elec_hi = trimmed_span(gain, signed)
|
|
phys_lo, phys_hi = elec_lo * gain, elec_hi * gain
|
|
|
|
external = "Input + Output" if writable else "Input"
|
|
scan = "MOD_SCAN"
|
|
|
|
name = "%s:%s" % (STATION, row["point"])
|
|
return record([
|
|
q(name), q(STATION), q(row["point"]), q(row["description"][:60]), q(io_addr),
|
|
q(external), q(scan), q(conv), 0, 0, 0,
|
|
_num(phys_lo), _num(phys_hi), 1, 0, 1 if signed else 0, 0,
|
|
0, 0, _num(elec_lo), _num(elec_hi), 0, 0,
|
|
0, 0, 0, bits, 16, 4,
|
|
q("Intel"), q("Date+time GMT"), q("7 bytes IEC"), q("Float value"),
|
|
])
|
|
|
|
|
|
def _num(v):
|
|
"""Whole numbers stay whole; fractions keep enough digits to be exact.
|
|
|
|
Four decimals, not one: a gain of 0.001 puts PHYS_HIGH at 32.767, and
|
|
rounding that to 32.8 would bend the conversion by 0.1%.
|
|
"""
|
|
return int(v) if float(v) == int(float(v)) else round(float(v), 4)
|
|
|
|
|
|
def build_item(row, nsid, parent_nsid, id_number):
|
|
digital = row["data_type"] == "Boolean"
|
|
section = row["section"]
|
|
path = "%s.%s" % (ROOT, section)
|
|
name = "%s.%s" % (path, row["leaf"])
|
|
|
|
item_rep = "Boolean" if digital else "Real"
|
|
value_format = "" if digital else row["format_mask"]
|
|
eng_unit = row["eng_units"]
|
|
|
|
f = {k: 0 for k in ITEM_FIELDS}
|
|
f.update({k: q("") for k in ITEM_FIELDS if k.startswith(
|
|
("COMMENT", "ITEM_STAT", "ALARM_TEXT", "ALARM_COLOR", "MNEMONIC", "AOI"))})
|
|
|
|
f["NAME"] = q(name)
|
|
f["NSID"] = nsid
|
|
f["PARENT_NSID"] = parent_nsid
|
|
f["SECTION_PATH"] = q(path)
|
|
f["DESCRIPTION"] = q(row["description"][:60])
|
|
f["VALUE_FORMAT"] = q(value_format)
|
|
f["NAME_IN_ITM_TAB"] = 1
|
|
f["STRING_LENGTH"] = 1
|
|
f["ID_GROUP"] = 1
|
|
f["ID_NUMBER"] = id_number
|
|
f["ITEM_REP"] = q(item_rep)
|
|
f["ITEM_TYPE"] = q("")
|
|
f["ITEM_SPECIAL"] = q("")
|
|
f["ACKN_TYPE"] = q("")
|
|
f["ALARM_GROUP"] = q("")
|
|
f["FRONT_END_NODE"] = q("UNLICENCED")
|
|
f["DISTR_TYPE"] = q("Local Host")
|
|
f["INSTALL"] = q("WRPS")
|
|
f["UNIT"] = q(section)
|
|
f["TAG"] = q(row["leaf"])
|
|
f["LIMIT_CLAMP"] = q("")
|
|
f["COL_GROUP"] = q("")
|
|
f["STATION"] = q(STATION)
|
|
f["POINT"] = q(row["point"])
|
|
f["FO_GROUP"] = q("")
|
|
for i in range(1, 7):
|
|
f["ALARM_STATE_%d" % i] = q("Normal")
|
|
if digital:
|
|
f["ITEM_STAT_1"] = q("BOOLEAN 0")
|
|
f["ITEM_STAT_2"] = q("BOOLEAN 1")
|
|
f["ENG_UNIT"] = q(eng_unit)
|
|
f["PROCESS_LIST"] = q("")
|
|
f["POINT_NAME"] = q("%s:%s" % (STATION, row["point"]))
|
|
f["OPC_AE_STATION_NAME"] = q("")
|
|
f["OPC_EVENT_SOURCE"] = q("")
|
|
f["OPC_EVENT_SOURCE_NAME"] = q(":")
|
|
f["CREATED_BY"] = q("unknown")
|
|
for i in range(1, 7):
|
|
f["SHELVE_ENABLED_%d" % i] = 1
|
|
f["AGG_INTERVAL"] = q("")
|
|
|
|
return record([f[k] for k in ITEM_FIELDS])
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Emit CI Server .qli imports.")
|
|
ap.add_argument("--address-base", type=int, choices=(0, 1), default=1,
|
|
help="IO_ADDRESS numbering: 1 = protocol address + 1 (default)")
|
|
ap.add_argument("--nsid-base", type=int, default=171,
|
|
help="first free NSID; sections take six from here, items follow")
|
|
ap.add_argument("--parent-nsid", type=int, default=170,
|
|
help="NSID of AID.WRPS, which the six sections hang off")
|
|
ap.add_argument("--include-root", action="store_true",
|
|
help="also emit AID and AID.WRPS sections (they already exist here)")
|
|
ap.add_argument("--section-nsid", action="append", default=[],
|
|
metavar="SECTION=NSID", help="pin a section's NSID, e.g. STN=41")
|
|
ap.add_argument("--version", default="1.03.00", help="@VERSION written into both files")
|
|
args = ap.parse_args()
|
|
|
|
if not POINTS.is_file():
|
|
sys.exit("missing scada-points.csv - run gen_scada_points.py first")
|
|
|
|
pinned = {}
|
|
for spec in args.section_nsid:
|
|
k, _, v = spec.partition("=")
|
|
pinned[k.strip().upper()] = int(v)
|
|
|
|
rows = list(csv.DictReader(open(POINTS, newline="", encoding="utf-8")))
|
|
prepared = []
|
|
for r in rows:
|
|
iec = r["iec_address"]
|
|
if iec not in LEAF:
|
|
sys.exit("no leaf name defined for %s - add it to LEAF" % iec)
|
|
section, leaf = LEAF[iec]
|
|
r["section"] = section
|
|
r["leaf"] = leaf
|
|
r["point"] = ("%s_%s" % (section, leaf))[:24]
|
|
prepared.append(r)
|
|
|
|
# NSIDs: one per section, then one per item, from --nsid-base.
|
|
next_id = args.nsid_base
|
|
section_nsid = {}
|
|
for s in SECTION_ORDER:
|
|
if s in pinned:
|
|
section_nsid[s] = pinned[s]
|
|
else:
|
|
section_nsid[s] = next_id
|
|
next_id += 1
|
|
|
|
section_out = [header(args.version, SECTION_FIELDS, "@SECTION_DF")]
|
|
if args.include_root:
|
|
root, _, wrps_leaf = ROOT.partition(".")
|
|
section_out.append(build_section(root, "", root, args.parent_nsid - 1, 0,
|
|
"Waterloo Road Pump Station demo"))
|
|
section_out.append(build_section(ROOT, root, wrps_leaf, args.parent_nsid,
|
|
args.parent_nsid - 1,
|
|
"Waterloo Road Pump Station"))
|
|
for s in SECTION_ORDER:
|
|
section_out.append(build_section("%s.%s" % (ROOT, s), ROOT, s,
|
|
section_nsid[s], args.parent_nsid,
|
|
SECTION_DESC[s]))
|
|
|
|
point_out = [header(args.version, POINT_FIELDS, "@MODBUS_POINT_DF")]
|
|
item_out = [header(args.version, ITEM_FIELDS, "@ITEM_DF")]
|
|
|
|
# NSID and ID_NUMBER follow register order, which is also the order the
|
|
# items were first imported in. Keep it stable: a re-import must update
|
|
# the existing items in place, not renumber them - the HMI's item ids in
|
|
# 05-scada/hmi/item-ids.csv are derived from that creation order.
|
|
for n, r in enumerate(prepared):
|
|
addr = int(r["modbus_address"]) + args.address_base
|
|
# Zero-padded to at least two digits, as the reference export does
|
|
# ("RI:01", not "RI:1"). Wider addresses keep their own width.
|
|
io_addr = "%s:%02d" % (PREFIX[r["function_code"]], addr)
|
|
point_out.append(build_point(r, io_addr))
|
|
item_out.append(build_item(r, next_id + n, section_nsid[r["section"]], n + 1))
|
|
|
|
# The header already ends in a newline, so the first record follows it
|
|
# directly; records are then separated by one blank line, exactly as
|
|
# the reference exports are.
|
|
def assemble(parts):
|
|
return parts[0] + "\n".join(parts[1:])
|
|
|
|
(HERE / "wrps_section_df.qli").write_text(assemble(section_out), encoding="utf-8")
|
|
(HERE / "wrps_modbus_point_df.qli").write_text(assemble(point_out), encoding="utf-8")
|
|
(HERE / "wrps_item_df.qli").write_text(assemble(item_out), encoding="utf-8")
|
|
|
|
n_sections = len(SECTION_ORDER) + (2 if args.include_root else 0)
|
|
print("OK wrps_section_df.qli (%d sections under %s)" % (n_sections, ROOT))
|
|
print("OK wrps_modbus_point_df.qli (%d points, station %s)" % (len(prepared), STATION))
|
|
print("OK wrps_item_df.qli (%d items under %s.*)" % (len(prepared), ROOT))
|
|
print(" address base : %d (holding register 0 -> RO:%02d)" % (args.address_base, args.address_base))
|
|
print(" section NSIDs: " + ", ".join("%s=%d" % (s, section_nsid[s]) for s in SECTION_ORDER))
|
|
print(" item NSIDs : %d-%d" % (next_id, next_id + len(prepared) - 1))
|
|
by_sec = {}
|
|
for r in prepared:
|
|
by_sec.setdefault(r["section"], 0)
|
|
by_sec[r["section"]] += 1
|
|
print(" per section : " + ", ".join("%s=%d" % (s, by_sec.get(s, 0)) for s in SECTION_ORDER))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|