ci-server-engineer/examples/CI-Eng-Sample/displays/generator/cixml.py
cliu c9d2ead5d9 Sample: CIENG_* operator HMI displays, generator and CIENG_Operator / CIENG_Popup layouts
- displays/CIENG_{Header,Overview,Control,Trends,Alarms,System,AlarmBanner,TankFaceplate}.xml:
  dark dashboard in HTML mode (desktop + web), generated by displays/generator/build.py
  (cixml.py component library; README.md describes the tool and the R1.03 rules it follows)
- layouts/CIENG_Operator.xml now holds CIENG_Operator (menu, navigation toolbar, header frame,
  5-tab set, alarm banner, status line; navigate commands with stretchDisplay) and CIENG_Popup
  (faceplate); layout.ps1 message updated
- deploy_displays.cmd / remove_displays.cmd for the 8 new displays
- Live-data Start runs C:\Users\Public\CI-Eng-Sample\feed\cieng_feed_start.cmd on the server
- Tested on CI Server R1.03.00 2026-10-06 (ADMIN with ?l=CIENG_Operator / jwuipoi -layout):
  web layout, tabs, popup, faceplate navigation, combo write, server-side Start as CIX_PROCESS;
  desktop layout, stretched tabs, Start/Stop
- Old CIENG_Tank / CIENG_Menu / CIENG_TrendLab still present (removed in the next stage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:23:54 +11:00

583 lines
37 KiB
Python

"""Small XML writer for CI Server R1.03 display files (Edit Module format 1.3.0.0).
Positions are given as boxes (left, top, width, height) in display pixels; CI Server stores a
component as its centre (x, y) plus half sizes (top, bottom, left, right).
Element and property names follow the installed component definitions
(%TLS_WAP_PATH%\\cfg\\operatorInterfaces\\DEPLOY\\components\\*.xml) and factory displays.
"""
from xml.sax.saxutils import escape
# ---------------------------------------------------------------- theme (dark dashboard)
C = {
'bg': (13, 17, 23), 'panel': (22, 27, 34), 'card': (28, 35, 45), 'card2': (36, 45, 57),
'line': (48, 61, 77), 'text': (230, 237, 243), 'muted': (139, 152, 168), 'dim': (88, 100, 116),
'blue': (59, 130, 246), 'teal': (20, 184, 166), 'green': (34, 197, 94), 'amber': (245, 158, 11),
'red': (239, 68, 68), 'violet': (139, 92, 246), 'off': (71, 85, 105), 'white': (255, 255, 255),
'track': (40, 50, 64),
}
FONT_UI = 'Segoe UI'
FONT_NUM = 'Consolas'
def rgb(c):
c = C.get(c, c) if isinstance(c, str) else c
return 'r="%d" g="%d" b="%d"' % tuple(c)
def colorset(tag, c):
return '<%s type="colorSet" %s/>' % (tag, rgb(c))
def paint(tag, c):
return '<%s type="paint"><paint type="colorSet" %s/></%s>' % (tag, rgb(c), tag)
def font(size, bold=False, name=FONT_UI, tag='font'):
b = ' bold="true"' if bold else ''
return '<%s type="font" name="%s"%s size="%d" underline="false" strikethrough="false"/>' % (tag, name, b, size)
def esc(s):
return escape(str(s))
def box_attrs(l, t, w, h):
return 'x="%.1f" y="%.1f" top="%.1f" bottom="%.1f" left="%.1f" right="%.1f" rotation="0.0" shear="0.0"' % (
l + w / 2.0, t + h / 2.0, h / 2.0, h / 2.0, w / 2.0, w / 2.0)
def box_elems(l, t, w, h):
return ('<x>%.1f</x><y>%.1f</y><top>%.1f</top><bottom>%.1f</bottom><left>%.1f</left><right>%.1f</right>'
'<rotation>0.0</rotation><shear>0.0</shear>') % (l + w / 2.0, t + h / 2.0, h / 2.0, h / 2.0, w / 2.0, w / 2.0)
_FONTS = {}
def text_width(s, size, bold=False, name=FONT_UI):
"""Width in display pixels, measured with the Windows font (matches the HTML export within ~3 %)."""
try:
from PIL import ImageFont
fn = {(FONT_UI, False): 'segoeui.ttf', (FONT_UI, True): 'segoeuib.ttf',
(FONT_NUM, False): 'consola.ttf', (FONT_NUM, True): 'consolab.ttf'}[(name, bool(bold))]
key = (fn, size)
if key not in _FONTS:
_FONTS[key] = ImageFont.truetype(r'C:\Windows\Fonts\%s' % fn, size)
return _FONTS[key].getlength(s) * 0.975
except Exception:
return len(s) * size * 0.55
def fit(l, w, s, size, align, bold=False, name=FONT_UI):
"""Text and numbers are exported with keepOriginalSize: drawn at natural size, centred on the box.
Shrink the box to the measured text width so 'Left' / 'Right' alignment still lands right."""
est = min(w, max(4.0, text_width(s, size, bold, name)))
if align == 'Left':
return l, est
if align == 'Right':
return l + w - est, est
return l, w
# ---------------------------------------------------------------- data connections
def tr_linear(i0, i1, o0, o1):
return ('<transformation type="transformationLinear"><inputType>java.lang.Double</inputType>'
'<outputType>java.lang.Double</outputType><inputValueStart>%s</inputValueStart>'
'<inputValueEnd>%s</inputValueEnd><outputValueStart>%s</outputValueStart>'
'<outputValueEnd>%s</outputValueEnd></transformation>') % (float(i0), float(i1), float(o0), float(o1))
def tr_threshold(table, inp='Double', out='Paint'):
"""table: list of (input, output). Output for Paint = colour key/tuple, Boolean = bool."""
itype = {'Double': 'java.lang.Double', 'Boolean': 'java.lang.Boolean'}[inp]
otype = {'Paint': 'fasttools.jwui.common.property.paint.PaintSet', 'Boolean': 'java.lang.Boolean',
'Color': 'fasttools.jtools.common.property.color.ColorSet'}[out]
rows = []
for i, o in table:
iv = ('true' if i else 'false') if inp == 'Boolean' else repr(float(i))
if out == 'Paint':
ov = '<thresholdOut type="paint"><paint type="colorSet" %s/></thresholdOut>' % rgb(o)
elif out == 'Color':
ov = '<thresholdOut type="colorSet" %s/>' % rgb(o)
else:
ov = '<thresholdOut>%s</thresholdOut>' % ('true' if o else 'false')
rows.append('<thresholdIn>%s</thresholdIn>%s' % (iv, ov))
return ('<transformation type="transformationThreshold"><inputType>%s</inputType><outputType>%s</outputType>'
'<thresholdTable type="thresholdTable">%s</thresholdTable></transformation>') % (itype, otype, ''.join(rows))
def conn(prop, item, attr='ItemValue', direction=1, transform=''):
"""Connect component property <prop> (e.g. 'number.value') to an item attribute.
direction 1 = read, 2 = write, None = read and write (as the factory toggle buttons)."""
d = '' if direction is None else '<direction>%d</direction>' % direction
return ('<action type="actionConnectTo"><property type="property" name="%s"/>'
'<filter type="filter"><value>0.0</value></filter>%s<connection type="connection">%s'
'<itemName>%s</itemName><itemAttribute>%s</itemAttribute></connection></action>') % (
prop, transform, d, esc(item), attr)
def act_set(item, value):
return ('<action type="actionSetItemAttribute"><event type="event" event="On action"/>'
'<filter type="filter"><value>%s</value></filter><connection type="connection"><direction>2</direction>'
'<location type="nodeName" node="$default$"/><itemName>%s</itemName><itemAttribute>ItemValue</itemAttribute>'
'</connection></action>') % (float(value), esc(item))
def act_open(display, layout=None, frame=None, event='On action'):
lay = ''
if layout or frame:
lay = ('<type type="displayActivationType" value="ActivateSpecific"/><display>%s</display>'
'<layout>%s</layout><layoutFrame>%s</layoutFrame>') % (display, layout or '', frame or '')
return '<action type="actionActivateDisplay"><event type="event" event="%s"/>%s</action>' % (event, lay)
return ('<action type="actionActivateDisplay"><event type="event" event="%s"/><display>%s</display>'
'</action>') % (event, display)
def act_close(event='On action'):
return '<action type="actionExitDisplay"><event type="event" event="%s"/></action>' % event
def act_cmd(command, server=True, event='On action'):
return ('<action type="actionExecuteCommand"><event type="event" event="%s"/><command>%s</command>'
'<onServer>%s</onServer></action>') % (event, esc(command), 'true' if server else 'false')
def data(actions):
return '<data type="data">%s</data>' % ''.join(actions) if actions else ''
# ---------------------------------------------------------------- display builder
class Display:
def __init__(self, name, width, height, title, bg='bg', design_h=None):
self.name, self.w, self.h, self.title, self.bg = name, width, height, title, bg
# Coordinates are written for a display design_h high and stretched vertically to height:
# panels, bars and lists grow, text / controls / round shapes keep their size and only move.
self.sy = float(height) / design_h if design_h else 1.0
self.parts = []
self.hid = 6 # 1 = layer, 2..6 = visibility groups
def _b(self, l, t, w, h, stretch):
if self.sy == 1.0:
return l, t, w, h
if stretch:
return l, t * self.sy, w, h * self.sy
return l, (t + h / 2.0) * self.sy - h / 2.0, w, h
def _id(self):
self.hid += 1
return '<htmlId>%d</htmlId>' % self.hid
def add(self, s):
self.parts.append(s)
# ----- shapes
def rect(self, l, t, w, h, fill='card', stroke=None, sw=1.0, r=0, actions=(), tip=None, fill_amount=None,
fill_dir=None, visible=True):
l, t, w, h = self._b(l, t, w, h, h > 40)
s = '<rectangle type="componentData">' + self._id()
if tip:
s += '<toolTipText>%s</toolTipText>' % esc(tip)
if r:
# stored as a fraction (0..1) of the half width / half height (web client
# ExtendedRectangle: rx = width / 2 * roundingX); pixel values > 1 draw an ellipse
s += '<roundingX>%.4f</roundingX><roundingY>%.4f</roundingY>' % (min(1.0, 2.0 * r / w), min(1.0, 2.0 * r / h))
s += box_elems(l, t, w, h)
s += '<stroke type="stroke" width="%.1f"/>' % sw
if stroke:
s += '<strokeOn>true</strokeOn>' + paint('strokePaint', stroke)
else:
s += '<strokeOn>false</strokeOn>'
if fill:
s += '<fillOn>true</fillOn>' + paint('fillPaint', fill)
else:
s += '<fillOn>false</fillOn>'
if fill_amount is not None:
s += '<fillAmount>%s</fillAmount>' % fill_amount
if fill_dir:
s += '<fillDirection type="fillDirection" value="%s"/>' % fill_dir
if not visible:
s += '<visible>false</visible>'
s += data(actions) + '</rectangle>'
self.add(s)
def ellipse(self, l, t, w, h, fill='green', stroke=None, sw=1.0, actions=(), tip=None):
l, t, w, h = self._b(l, t, w, h, False)
s = '<ellipse type="componentData">' + self._id()
if tip:
s += '<toolTipText>%s</toolTipText>' % esc(tip)
s += box_elems(l, t, w, h) + '<stroke type="stroke" width="%.1f"/>' % sw
s += ('<strokeOn>true</strokeOn>' + paint('strokePaint', stroke)) if stroke else '<strokeOn>false</strokeOn>'
s += ('<fillOn>true</fillOn>' + paint('fillPaint', fill)) if fill else '<fillOn>false</fillOn>'
s += data(actions) + '</ellipse>'
self.add(s)
def arc(self, cx, cy, rad, start, extent, color='blue', sw=10.0, actions=()):
cy = cy * self.sy
s = '<arc type="componentData">' + self._id()
s += '<startAngle>%.1f</startAngle><extentAngle>%.1f</extentAngle><closed>false</closed>' % (start, extent)
s += box_elems(cx - rad, cy - rad, 2 * rad, 2 * rad)
s += '<stroke type="stroke" width="%.1f" endCap="butt"/><strokeOn>true</strokeOn>' % sw
s += paint('strokePaint', color) + '<fillOn>false</fillOn>'
s += data(actions) + '</arc>'
self.add(s)
def line(self, x1, y1, x2, y2, color='line', sw=2.0, actions=()):
# a line is stored by its bounding box; direction by the sign of the extents is not available,
# so only horizontal / vertical lines are used here
l, t = min(x1, x2), min(y1, y2)
w, h = abs(x2 - x1), abs(y2 - y1)
# drawn as a thin filled rectangle: a <line> without curve points is placed wrongly on the web
if h < w:
self.rect(l, t - sw / 2.0, max(w, 1), sw, fill=color, actions=actions)
else:
self.rect(l - sw / 2.0, t, sw, max(h, 1), fill=color, actions=actions)
# ----- text and numbers
def text(self, l, t, w, h, value, size=12, color='text', bold=False, align='Left', actions=(), name=FONT_UI,
bgfill=None):
l, t, w, h = self._b(l, t, w, h, False)
s = '<text type="componentData">' + self._id() + '<value>%s</value>' % esc(value)
s += font(size, bold, name) + paint('fillPaint', color)
l, w = fit(l, w, str(value), size, align, bold, name)
s += '<justification type="textJustification" value="TextJustificationCenter"/>'
if bgfill:
s += '<backgroundOn>true</backgroundOn>' + paint('backgroundPaint', bgfill)
s += '<stroke type="stroke" width="1.0"/><keepOriginalSize>true</keepOriginalSize>'
s += box_elems(l, t, w, h) + data(actions) + '</text>'
self.add(s)
def number(self, l, t, w, h, item, fmt='999.9', size=22, color='text', bold=True, align='Right', attr='ItemValue',
extra=()):
l, t, w, h = self._b(l, t, w, h, False)
s = '<number type="componentData">' + self._id() + '<value>0.0</value><format>%s</format>' % fmt
s += font(size, bold, FONT_NUM) + paint('fillPaint', color)
l, w = fit(l, w, fmt.replace('9', '0'), size, align, bold, FONT_NUM)
s += '<justification type="textJustification" value="TextJustificationCenter"/>'
s += '<stroke type="stroke" width="1.0"/><keepOriginalSize>true</keepOriginalSize>'
s += box_elems(l, t, w, h) + data([conn('number.value', item, attr)] + list(extra)) + '</number>'
self.add(s)
def number_field(self, l, t, w, h, item, fmt='9999', size=14, editable=True, attr='ItemValue', as_datetime=False):
l, t, w, h = self._b(l, t, w, h, False)
s = '<numberField type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += font(size, True, FONT_NUM) + '<format>%s</format>' % fmt
s += '<editable>%s</editable>' % ('true' if editable else 'false')
if as_datetime:
s += '<asDateTime>true</asDateTime>'
s += colorset('foregroundColor', 'text') + colorset('backgroundColor', 'card2')
s += '<justification type="jTextJustification" value="TextJustificationRight"/>'
s += data([conn('numberField.value', item, attr, direction=None if editable else 1)]) + '</numberField>'
self.add(s)
def text_field(self, l, t, w, h, item, attr='ItemDescription', size=12):
l, t, w, h = self._b(l, t, w, h, False)
s = '<textField type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += font(size) + '<editable>false</editable>'
s += colorset('foregroundColor', 'text') + colorset('backgroundColor', 'card2')
s += data([conn('textField.value', item, attr)]) + '</textField>'
self.add(s)
def time_text(self, l, t, w, h, item, size=12, color='text', align='Left'):
"""Last update time of an item: number field with asDateTime (epoch seconds -> local date and time).\n The web dateTimeField only shows a value when editable (white input boxes)."""
self.number_field(l, t, w, h, item, '', size, editable=False, attr='ItemUpdTime', as_datetime=True)
def datetime_field(self, l, t, w, h, item, attr='ItemUpdTime', size=12, editable=True):
l, t, w, h = self._b(l, t, w, h, False)
s = '<dateTimeField type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += font(size) + colorset('foregroundColor', 'text')
s += '<editable>%s</editable><secondsVisible>true</secondsVisible>' % ('true' if editable else 'false')
s += colorset('backgroundColor', 'card2')
s += data([conn('dateTimeField.value', item, attr)]) + '</dateTimeField>'
self.add(s)
# ----- controls
def button(self, l, t, w, h, label, actions, fg='text', bg='card2', size=12, bold=True, tip=None, extra=()):
l, t, w, h = self._b(l, t, w, h, False)
s = '<button type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
if tip:
s += '<toolTipText>%s</toolTipText>' % esc(tip)
s += '<label>%s</label>' % esc(label) + font(size, bold)
s += colorset('foregroundColor', fg) + colorset('backgroundColor', bg)
s += data(list(actions) + list(extra)) + '</button>'
self.add(s)
def toggle(self, l, t, w, h, label, item, size=12):
l, t, w, h = self._b(l, t, w, h, False)
s = '<toggleButton type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += '<label>%s</label>' % esc(label) + font(size, True)
s += colorset('foregroundColor', 'text') + colorset('backgroundColor', 'card2')
s += data([conn('toggleButton.value', item, direction=None)]) + '</toggleButton>'
self.add(s)
def checkbox(self, l, t, w, h, label, item, size=12):
l, t, w, h = self._b(l, t, w, h, False)
s = '<checkBox type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += '<label>%s</label>' % esc(label) + font(size)
s += '<opaque>false</opaque>' + colorset('foregroundColor', 'text') + colorset('backgroundColor', 'card')
s += data([conn('checkBox.value', item, direction=None)]) + '</checkBox>'
self.add(s)
def slider(self, l, t, w, h, item, mn, mx, major, minor=None, size=10):
l, t, w, h = self._b(l, t, w, h, False)
s = '<slider type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += '<maximum>%s</maximum><minimum>%s</minimum><value>%s</value>' % (float(mx), float(mn), float(mn))
s += '<majorTickSpacing>%s</majorTickSpacing><minorTickSpacing>%s</minorTickSpacing>' % (
float(major), float(minor or major))
s += '<paintLabels>true</paintLabels><paintTicks>true</paintTicks><trackDrag>false</trackDrag>'
s += font(size) + '<opaque>false</opaque>' + colorset('foregroundColor', 'muted')
s += data([conn('slider.value', item, direction=None)]) + '</slider>'
self.add(s)
def spinner(self, l, t, w, h, item, mn, mx, step=1, size=14):
l, t, w, h = self._b(l, t, w, h, False)
s = '<spinner type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += '<editable>true</editable><step>%s</step><minimum>%s</minimum><maximum>%s</maximum>' % (
float(step), float(mn), float(mx))
s += font(size, True, FONT_NUM) + colorset('foregroundColor', 'text') + colorset('backgroundColor', 'card2')
s += '<zoomEnabled>false</zoomEnabled>'
s += data([conn('spinner.value', item, direction=None)]) + '</spinner>'
self.add(s)
def combo(self, l, t, w, h, item, options, size=12):
l, t, w, h = self._b(l, t, w, h, False)
"""options: 'value;Text;value;Text' (web client format)."""
s = '<comboBox type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += '<label>%s</label>' % esc(options) + font(size) + colorset('foregroundColor', 'text')
s += data([conn('comboBox.value', item, direction=None)]) + '</comboBox>'
self.add(s)
# ----- indicators
def databar(self, l, t, w, h, item, lo=0, hi=100, fg='blue', bg='track', direction='Up', border=None):
l, t, w, h = self._b(l, t, w, h, h > w)
s = '<dataBar type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += '<fillOn>true</fillOn>' + paint('background', bg) + paint('foreground', fg)
s += paint('borderColor', border or bg) + paint('foregroundBorderColor', fg)
s += '<lowLimit>%s</lowLimit><highLimit>%s</highLimit>' % (float(lo), float(hi))
s += '<growthDirection type="growthDirection" value="GrowthDirection%s"/>' % direction
s += data([conn('dataBar.value', item)]) + '</dataBar>'
self.add(s)
def rect_scale(self, l, t, w, h, mn, mx, major, minor, vertical=True, fmt='999', size=10):
l, t, w, h = self._b(l, t, w, h, h > w)
s = '<rectangularScale type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += '<maximum>%s</maximum><minimum>%s</minimum><majorTickSpacing>%s</majorTickSpacing>' % (
float(mx), float(mn), float(major))
s += '<minorTickSpacing>%s</minorTickSpacing><format>%s</format><vertical>%s</vertical>' % (
float(minor), fmt, 'true' if vertical else 'false')
s += colorset('color', 'muted') + font(size) + '</rectangularScale>'
self.add(s)
def circ_scale(self, cx, cy, rad, mn, mx, major, minor, start=225, extent=-270, fmt='999', size=9):
cy = cy * self.sy
s = '<circularScale type="componentData" %s>' % box_attrs(cx - rad, cy - rad, 2 * rad, 2 * rad) + self._id()
s += '<maximum>%s</maximum><minimum>%s</minimum><majorTickSpacing>%s</majorTickSpacing>' % (
float(mx), float(mn), float(major))
s += '<minorTickSpacing>%s</minorTickSpacing><format>%s</format>' % (float(minor), fmt)
s += '<startAngle>%s</startAngle><extentAngle>%s</extentAngle>' % (float(start), float(extent))
s += colorset('color', 'muted') + font(size) + '</circularScale>'
self.add(s)
def ring_gauge(self, cx, cy, rad, item, mn, mx, color='teal', sw=12.0, thresholds=None):
"""270 degree ring with the gap on the right; the value arc grows from 9 o'clock up and down.
The Operator Interface (desktop) draws arc angles mirrored about the horizontal axis compared
with the web client, so only shapes symmetric about that axis look the same on both:
track 45..315 degrees, value arc centred on 180 degrees (start = 180 - extent / 2)."""
self.arc(cx, cy, rad, 45, 270, 'track', sw)
acts = [conn('arc.extentAngle', item, transform=tr_linear(mn, mx, 0.5, 270)),
conn('arc.startAngle', item, transform=tr_linear(mn, mx, 179.75, 45))]
if thresholds:
acts.append(conn('arc.strokePaint', item, transform=tr_threshold(thresholds)))
self.arc(cx, cy, rad, 179.75, 0.5, color, sw, acts)
def lamp(self, l, t, d, item, on='green', off='off', inp='Boolean', table=None):
tbl = table or [(False, off), (True, on)]
self.ellipse(l, t, d, d, fill=off, actions=[conn('ellipse.fillPaint', item,
transform=tr_threshold(tbl, inp))])
# ----- complex components
def alarm_overview(self, l, t, w, h, overview='CurrentAlarms', asa=None, size=12, banner=False):
l, t, w, h = self._b(l, t, w, h, True)
s = '<alarmOverview type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += '<zoomEnabled>false</zoomEnabled>'
s += '<overviewType type="alarmOverview" value="%s"/>' % overview
# dark rows: background, unacknowledged / acknowledged row colour per priority, text colours
s += '<opaque>true</opaque>' + colorset('backgroundColor', 'panel')
for c, col in (('Alarm', (248, 113, 113)), ('Normal', 'text'), ('Ackn', 'muted'), ('Info', 'muted'),
('Diag', 'amber'), ('Deactive', 'muted'), ('Recover', 'green')):
s += colorset('eventType%sAckColor' % c, col) + colorset('eventType%sNackColor' % c, col)
for p in range(0, 16):
s += ('<property type="componentProperty"><name>priorityNackColor[%02d]</name><value type="colorSet" %s/></property>'
'<property type="componentProperty"><name>priorityAckColor[%02d]</name><value type="colorSet" %s/></property>'
) % (p, rgb((64, 24, 30) if p <= 3 else (36, 45, 57)), p, rgb('card'))
s += font(size) + font(size, False, FONT_UI, 'alarmLineFont') + font(size - 1, True, FONT_UI, 'buttonFont')
# explicit columns: order and widths spread over the whole component width (autoFit off)
cols = [('AlarmTime', 200), ('Priority', 70), ('Section', 300), ('Tag', 170), ('ItemDesciption', 320),
('Text', 300), ('AlarmType', 120), ('Value', 110), ('Acknowledged', 110)]
scale = (w - 24.0) / sum(c[1] for c in cols)
for i, (c, cw) in enumerate(cols):
s += '<column%sVisible>true</column%sVisible><column%sPosition>%d</column%sPosition>' % (c, c, c, i + 1, c)
s += '<column%sWidth>%d</column%sWidth>' % (c, int(cw * scale), c)
hide = ['CEventActor', 'CEventCat', 'CEventCondition', 'CEventSource', 'CEventSubCond', 'EventActor',
'EventCat', 'EventCondition', 'EventSource', 'EventSubCond', 'Installation', 'ItemName',
'OriginalTime', 'Quality', 'Unit', 'User', 'Sub', 'EngUnits', 'Location', 'InstallationDescription',
'UnitDescription', 'SectionDescription', 'AlarmCollection', 'EventID', 'ConditionID', 'BranchID',
'PriorityText'] # AlarmAttribute is an array property
for c in hide:
s += '<column%sVisible>false</column%sVisible>' % (c, c)
if banner:
s += ('<statusLineVisible>false</statusLineVisible><autoFit>false</autoFit><stopMoreVisible>false'
'</stopMoreVisible><headingVisible>false</headingVisible><dateTimeSelectorVisible>false'
'</dateTimeSelectorVisible><filterSelectorVisible>false</filterSelectorVisible>')
else:
# autoFit in a tab that is hidden while loading squeezes all columns to nothing (web)
s += '<autoFit>false</autoFit>'
s += '<highAlarmPriorityRange>1-1</highAlarmPriorityRange><mediumAlarmPriorityRange>2-2</mediumAlarmPriorityRange>'
s += '<lowAlarmPriorityRange>3-3</lowAlarmPriorityRange><diagnosticAlarmPriorityRange>0-0,4-15</diagnosticAlarmPriorityRange>'
if asa:
s += ('<userDataFilterType type="alarmUserFilter" value="UserDataFilterASA"/>'
'<userDataFilterValue>%s</userDataFilterValue>'
'<dataFilterType type="alarmDataFilter" value="DataFilterUserFiler"/>') % asa
s += '<data type="data"><action type="actionInternalServiced"/></data></alarmOverview>'
self.add(s)
def report(self, l, t, w, h, report=None):
l, t, w, h = self._b(l, t, w, h, True)
s = '<reporting type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
if report:
s += '<reportSelected>%s</reportSelected>' % esc(report)
s += '<data type="data"><action type="actionInternalServiced"/></data></reporting>'
self.add(s)
def micro_trend(self, l, t, w, h, pens, time_range=600, history=True):
l, t, w, h = self._b(l, t, w, h, True)
"""pens: list of (item, min, max, colour)."""
s = '<microTrend type="componentData" %s>' % box_attrs(l, t, w, h) + self._id()
s += '<numberOfPens>%d</numberOfPens><realTime>true</realTime><fillWithHistory>%s</fillWithHistory>' % (len(pens), 'true' if history else 'false')
s += '<microTrendBackgroundFillOn>true</microTrendBackgroundFillOn><lowHighFillOn>false</lowHighFillOn>'
s += colorset('microTrendBackgroundFillColor', 'panel') + colorset('microTrendBackgroundColor', 'line')
acts = ['<action type="actionInternalServiced"/>']
for i, (item, mn, mx, col) in enumerate(pens):
s += ('<property type="componentProperty"><name>itemName[%d]</name><value/></property>'
'<property type="componentProperty"><name>scaleMin[%d]</name><value>%s</value></property>'
'<property type="componentProperty"><name>scaleMax[%d]</name><value>%s</value></property>'
'<property type="componentProperty"><name>color[%d]</name><value type="colorSet" %s/></property>'
'<property type="componentProperty"><name>stroke[%d]</name><value type="stroke" width="2.0"/></property>'
) % (i, i, float(mn), i, float(mx), i, rgb(col), i)
acts.append(conn('microTrend.itemName[%d]' % i, item, 'ItemName'))
s += data(acts) + '</microTrend>'
self.add(s)
def trend(self, l, t, w, h, pens, minutes=10, title='', web_spares=True):
l, t, w, h = self._b(l, t, w, h, True)
"""pens: list of (item, colour, min, max, description). Padded to 50 visible pens (ISS-041)."""
def pen(item, col, mn, mx, desc, spare=0):
if spare:
col_x = colorset('color', 'panel')
stroke, legend, vis_leg, rng, mn_s, mx_s, typ = '0.0', ' ' * spare, 'false', 1, '1.0E30', '2.0E30', 1
desc = ''
else:
col_x = colorset('color', col)
stroke, legend, vis_leg, rng, mn_s, mx_s, typ = '2.0', '', 'true', 1, repr(float(mn)), repr(float(mx)), 0
return ('<pen type="pen"><type>%d</type>%s<stroke type="stroke" width="%s"/><paint>@NULL</paint>'
'<visible>true</visible><visibleLegend>%s</visibleLegend>'
'<source type="sourceID" uri="ci:/item/%s"/><axisVisible>%s</axisVisible><axisPosition>0</axisPosition>'
'<labelVisible>true</labelVisible><majorTickVisible>true</majorTickVisible><minorTickVisible>true</minorTickVisible>'
'<axisColor>@NULL</axisColor><labelColor>@NULL</labelColor><scaleRangeType>%d</scaleRangeType>'
'<minimum>%s</minimum><maximum>%s</maximum><procentual>false</procentual><title/>'
'<description>%s</description><legendText>%s</legendText><titlePlacement>0</titlePlacement>'
'<titleRotation>0</titleRotation><labelRotation>0</labelRotation><majorGridVisible>false</majorGridVisible>'
'<minorGridVisible>false</minorGridVisible><lowHighLimitGridVisible>false</lowHighLimitGridVisible>'
'<lowHighLimitGridAreaVisible>false</lowHighLimitGridAreaVisible>'
'<lowLowHighHighLimitGridVisible>false</lowLowHighHighLimitGridVisible><majorGridColor>@NULL</majorGridColor>'
'<minorGridColor>@NULL</minorGridColor><lowHighLimitGridColor>@NULL</lowHighLimitGridColor>'
'<lowHighLimitGridAreaColor>@NULL</lowHighLimitGridAreaColor>'
'<lowLowHighHighLimiGridColor>@NULL</lowLowHighHighLimiGridColor>'
'<majorGridStroke type="stroke" width="0.1"/><minorGridStroke type="stroke" width="0.1"/>'
'<lowHighLimitGridStroke type="stroke" width="0.1"/><lowLowHighHighLimiGridStroke type="stroke" width="0.1"/>'
'<annotationVisible>false</annotationVisible><timeShift>0</timeShift></pen>') % (
typ, col_x, stroke, vis_leg, esc(item), 'true' if (not spare and pens and item == pens[0][0]) else 'false',
rng, mn_s, mx_s, esc(desc), legend)
body = ''.join(pen(*p) for p in pens)
if web_spares: # the web trend draws only with 50 visible pens (ISS-041); the desktop hairline then lists 50 values
body += ''.join(pen(pens[0][0], None, 0, 0, '', i) for i in range(1, 51 - len(pens)))
s = ('<chart type="componentData">' + self._id() +
'<config type="graphicInfo"><version type="version" value="1.3.0.0"/>'
'<backgroundBorder type="border" value="BorderEmpty"/><penPanelVisible>false</penPanelVisible>'
'<penPanelHeight>-1</penPanelHeight><toolbarVisible>true</toolbarVisible>'
'<toolbarPosition type="borderLayoutPosition" value="PositionNorth"/><shiftOffset>0</shiftOffset>'
'<shiftLength>0</shiftLength><enterpriseGroupLocation type="nodeName" node="$display$"/>'
'<chartPanel><name/><author/><version type="version" value="1.3.0.0"/><description/>'
'<forAllUsers>false</forAllUsers><template>false</template><group>@NULL</group><XyMode>false</XyMode>'
'<x>0</x><y>0</y><width>%d</width><heigth>%d</heigth><iconized>false</iconized>'
'<backgroundBorder type="border" value="BorderEmpty"/>%s'
'<backgroundPaint type="paint"><paint type="colorSet" %s/></backgroundPaint>'
'<plotAreaBorder type="border" value="BorderEmpty"/><plotAreaTransparent>false</plotAreaTransparent>'
'<plotAreaIcon>@NULL</plotAreaIcon><plotAreaPaint type="paint"><paint type="colorSet" %s/></plotAreaPaint>'
'%s<hairlineVisible>false</hairlineVisible><antialiasingText>true</antialiasingText>'
'<antialiasingData>true</antialiasingData><renderMode type="chartRendering" value="Rendering2d"/>'
'<timeNavigatorVisible>true</timeNavigatorVisible><timeRangeControlVisible>true</timeRangeControlVisible>'
'<autoUpdateInterval>false</autoUpdateInterval><legendVisible>true</legendVisible><legendTitle/>'
'<legendHeading/>%s%s%s<legendTransparancy>0.0</legendTransparancy>'
'<legendBorderType type="border" value="BorderEmpty"/><legendPositionType type="position" value="PositionNorthEast"/>'
'<timeSliderVisible>true</timeSliderVisible><timeSliderAbove>false</timeSliderAbove>'
'<valueSliderVisible>false</valueSliderVisible><valueSliderLeft>false</valueSliderLeft>'
'<xAxisReversed>false</xAxisReversed><yAxisReversed>false</yAxisReversed><projectorReversed>false</projectorReversed>'
'<scalingFonts>false</scalingFonts><maximumPens>50</maximumPens><pensPerPack>0</pensPerPack>'
'<triggerPen>0</triggerPen><timeAxisVisible>true</timeAxisVisible><timeAxisMajorTickVisible>true</timeAxisMajorTickVisible>'
'<timeAxisMinorTickVisible>false</timeAxisMinorTickVisible>%s%s%s'
'<timeAxisLabelVisible>true</timeAxisLabelVisible><timeAxisLongLabelVisible>false</timeAxisLongLabelVisible>'
'<timeAxisLabelRotation>0</timeAxisLabelRotation><timeAxisTitle/><timeAxisTitleRotation>0</timeAxisTitleRotation>'
'<timeAxisTitlePlacement>50</timeAxisTitlePlacement><timeAxisMajorGridVisible>true</timeAxisMajorGridVisible>'
'%s<timeAxisMajorGridStroke type="stroke" width="1.0" endCap="butt" lineJoin="bevel"/>'
'<timeAxisMinorGridVisible>false</timeAxisMinorGridVisible>%s'
'<timeAxisMinorGridStroke type="stroke" width="1.0" endCap="butt" lineJoin="bevel"/>'
'<headerText>%s</headerText>%s%s<headerJustification type="jTextJustification" value="TextJustificationLeft"/>'
'<footerText/>%s%s<footerJustification type="jTextJustification" value="TextJustificationLeft"/>'
'<enterprisePrimarySourceLocation type="nodeName" node="$default$"/>'
'<enterpriseSecondarySourceLocation type="nodeName" node="$default$"/>'
'<enterprisePrimaryHistoryLocation type="nodeName" node="$default$"/>'
'<enterpriseSecondaryHistoryLocation type="nodeName" node="$default$"/>'
'<updateFromHistory>true</updateFromHistory><useSampleInterval>true</useSampleInterval>'
'<sampleInterval>0</sampleInterval><optimised>true</optimised>%s'
'<timeRange type="timeRangeLast" count="%d"><type type="timeUnit" value="TimeUnitMinute"/></timeRange>'
'<updateInterval>5</updateInterval><fillHistory>true</fillHistory><updating>true</updating>'
'</chartPanel></config>%s<data type="data"><action type="actionInternalServiced"/></data></chart>') % (
w, h, colorset('foregroundColor', 'muted'), rgb('panel'), rgb('panel'), colorset('hairlineColor', 'muted'),
colorset('legendColor', 'text'), colorset('legendBackgroundColor', 'panel'), font(11, False, FONT_UI, 'legendFont'),
colorset('timeAxisColor', 'muted'), font(11, False, FONT_UI, 'timeAxisFont'), colorset('timeAxisLabelColor', 'muted'),
colorset('timeAxisMajorGridColor', 'line'), colorset('timeAxisMinorGridColor', 'line'),
esc(title), colorset('headerColor', 'text'), font(12, True, FONT_UI, 'headerFont'),
colorset('footerColor', 'muted'), font(11, False, FONT_UI, 'footerFont'), body, minutes, box_elems(l, t, w, h))
self.add(s)
# ----- output
def xml(self):
vg = ''
for i, (n, d, z) in enumerate([('Overview', 'Always shown', 10.0), ('Rough', 'Shown when viewing viewing a large area', 25.0),
('Standard', 'Shown when using the default view setting', 100.0),
('Detail', 'Shown only when viewing a small area', 400.0),
('Intricacies', 'Shown only when viewing a very small area', 1000.0)]):
vg += ('<visibilityGroup type="componentData"><htmlId>%d</htmlId><name>%s</name><description>%s</description>'
'<minimumZoomEnabled>true</minimumZoomEnabled><minimumZoomFactor>%s</minimumZoomFactor>'
'</visibilityGroup>\n') % (i + 2, n, d, z)
head = ('<?xml version="1.0" encoding="utf-8" ?>\n<visualization protocolVersion="1.3.0.0">\n <globalSection/>\n'
' <coreObjectDefinition type="displayDefinition">\n<version type="version" value="1.3.0.0"/>\n'
'<title>%s</title>\n<html>true</html>\n<width>%d</width>\n<height>%d</height>\n'
'<referenceCheck>%d</referenceCheck>\n'
'%s\n<backgroundColorOn>true</backgroundColorOn>\n%s\n%s\n%s\n<defaultStroke type="stroke" width="1.0"/>\n'
'<grid type="grid" gridVisible="true" snappingActive="true" verticalSnapInterval="4" horizontalSnapInterval="4" onTop="false">'
'<color type="colorSet" r="51" g="59" b="71"/></grid>\n'
'<revisionHistory type="revisionHistory"><revision type="revision" who="ADMIN" when="2026.10.02 12:00:00.000 AEST" '
'what="Generated by displays/generator/build.py" where="localhost"/></revisionHistory>\n'
'<blinkDelay>500</blinkDelay>\n<blinkEnabled>true</blinkEnabled>\n<mousePassThrough>false</mousePassThrough>\n'
) % (esc(self.title), self.w, self.h, self.hid, colorset('backgroundColor', self.bg),
colorset('defaultBgColor', self.bg), colorset('defaultFgColor', 'text'), font(12, False, FONT_UI, 'defaultFont'))
layer = '<visualizationLayer type="componentData"><htmlId>1</htmlId><name>Layer1</name></visualizationLayer>\n'
body = '<componentCountHint>%d</componentCountHint>\n' % len(self.parts)
# full-size background rectangle first (the web client ignores the display background colour on some frames)
return (head + vg + layer + body + '\n'.join(self.parts) +
'\n </coreObjectDefinition>\n</visualization>\n')
def save(self, folder):
import os
with open(os.path.join(folder, self.name + '.xml'), 'w', encoding='utf-8', newline='\r\n') as f:
f.write(self.xml())