"""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">%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 ('%.1f%.1f%.1f%.1f%.1f%.1f'
'0.00.0') % (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 ('java.lang.Double'
'java.lang.Double%s'
'%s%s'
'%s') % (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 = '' % rgb(o)
elif out == 'Color':
ov = '' % rgb(o)
else:
ov = '%s' % ('true' if o else 'false')
rows.append('%s%s' % (iv, ov))
return ('%s%s'
'%s') % (itype, otype, ''.join(rows))
def conn(prop, item, attr='ItemValue', direction=1, transform=''):
"""Connect component property (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 '%d' % direction
return (''
'0.0%s%s'
'%s%s') % (
prop, transform, d, esc(item), attr)
def act_set(item, value):
return (''
'%s2'
'%sItemValue'
'') % (float(value), esc(item))
def act_open(display, layout=None, frame=None, event='On action'):
lay = ''
if layout or frame:
lay = ('%s'
'%s%s') % (display, layout or '', frame or '')
return '%s' % (event, lay)
return ('%s'
'') % (event, display)
def act_close(event='On action'):
return '' % event
def act_cmd(command, server=True, event='On action'):
return ('%s'
'%s') % (event, esc(command), 'true' if server else 'false')
def data(actions):
return '%s' % ''.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 '%d' % 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 = '' + self._id()
if tip:
s += '%s' % 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 += '%.4f%.4f' % (min(1.0, 2.0 * r / w), min(1.0, 2.0 * r / h))
s += box_elems(l, t, w, h)
s += '' % sw
if stroke:
s += 'true' + paint('strokePaint', stroke)
else:
s += 'false'
if fill:
s += 'true' + paint('fillPaint', fill)
else:
s += 'false'
if fill_amount is not None:
s += '%s' % fill_amount
if fill_dir:
s += '' % fill_dir
if not visible:
s += 'false'
s += data(actions) + ''
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 = '' + self._id()
if tip:
s += '%s' % esc(tip)
s += box_elems(l, t, w, h) + '' % sw
s += ('true' + paint('strokePaint', stroke)) if stroke else 'false'
s += ('true' + paint('fillPaint', fill)) if fill else 'false'
s += data(actions) + ''
self.add(s)
def arc(self, cx, cy, rad, start, extent, color='blue', sw=10.0, actions=()):
cy = cy * self.sy
s = '' + self._id()
s += '%.1f%.1ffalse' % (start, extent)
s += box_elems(cx - rad, cy - rad, 2 * rad, 2 * rad)
s += 'true' % sw
s += paint('strokePaint', color) + 'false'
s += data(actions) + ''
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 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 = '' + self._id() + '%s' % esc(value)
s += font(size, bold, name) + paint('fillPaint', color)
l, w = fit(l, w, str(value), size, align, bold, name)
s += ''
if bgfill:
s += 'true' + paint('backgroundPaint', bgfill)
s += 'true'
s += box_elems(l, t, w, h) + data(actions) + ''
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 = '' + self._id() + '0.0%s' % 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 += ''
s += 'true'
s += box_elems(l, t, w, h) + data([conn('number.value', item, attr)] + list(extra)) + ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += font(size, True, FONT_NUM) + '%s' % fmt
s += '%s' % ('true' if editable else 'false')
if as_datetime:
s += 'true'
s += colorset('foregroundColor', 'text') + colorset('backgroundColor', 'card2')
s += ''
s += data([conn('numberField.value', item, attr, direction=None if editable else 1)]) + ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += font(size) + 'false'
s += colorset('foregroundColor', 'text') + colorset('backgroundColor', 'card2')
s += data([conn('textField.value', item, attr)]) + ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += font(size) + colorset('foregroundColor', 'text')
s += '%strue' % ('true' if editable else 'false')
s += colorset('backgroundColor', 'card2')
s += data([conn('dateTimeField.value', item, attr)]) + ''
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 = ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += '' % esc(label) + font(size, True)
s += colorset('foregroundColor', 'text') + colorset('backgroundColor', 'card2')
s += data([conn('toggleButton.value', item, direction=None)]) + ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += '' % esc(label) + font(size)
s += 'false' + colorset('foregroundColor', 'text') + colorset('backgroundColor', 'card')
s += data([conn('checkBox.value', item, direction=None)]) + ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += '%s%s%s' % (float(mx), float(mn), float(mn))
s += '%s%s' % (
float(major), float(minor or major))
s += 'truetruefalse'
s += font(size) + 'false' + colorset('foregroundColor', 'muted')
s += data([conn('slider.value', item, direction=None)]) + ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += 'true%s%s%s' % (
float(step), float(mn), float(mx))
s += font(size, True, FONT_NUM) + colorset('foregroundColor', 'text') + colorset('backgroundColor', 'card2')
s += 'false'
s += data([conn('spinner.value', item, direction=None)]) + ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += '' % esc(options) + font(size) + colorset('foregroundColor', 'text')
s += data([conn('comboBox.value', item, direction=None)]) + ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += 'true' + paint('background', bg) + paint('foreground', fg)
s += paint('borderColor', border or bg) + paint('foregroundBorderColor', fg)
s += '%s%s' % (float(lo), float(hi))
s += '' % direction
s += data([conn('dataBar.value', item)]) + ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += '%s%s%s' % (
float(mx), float(mn), float(major))
s += '%s%s%s' % (
float(minor), fmt, 'true' if vertical else 'false')
s += colorset('color', 'muted') + font(size) + ''
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 = '' % box_attrs(cx - rad, cy - rad, 2 * rad, 2 * rad) + self._id()
s += '%s%s%s' % (
float(mx), float(mn), float(major))
s += '%s%s' % (float(minor), fmt)
s += '%s%s' % (float(start), float(extent))
s += colorset('color', 'muted') + font(size) + ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += 'false'
s += '' % overview
# dark rows: background, unacknowledged / acknowledged row colour per priority, text colours
s += 'true' + 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 += ('priorityNackColor[%02d]'
'priorityAckColor[%02d]'
) % (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 += 'true%d' % (c, c, c, i + 1, c)
s += '%d' % (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 += 'false' % (c, c)
if banner:
s += ('falsefalsefalse'
'falsefalse'
'false')
else:
# autoFit in a tab that is hidden while loading squeezes all columns to nothing (web)
s += 'false'
s += '1-12-2'
s += '3-30-0,4-15'
if asa:
s += (''
'%s'
'') % asa
s += ''
self.add(s)
def report(self, l, t, w, h, report=None):
l, t, w, h = self._b(l, t, w, h, True)
s = '' % box_attrs(l, t, w, h) + self._id()
if report:
s += '%s' % esc(report)
s += ''
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 = '' % box_attrs(l, t, w, h) + self._id()
s += '%dtrue%s' % (len(pens), 'true' if history else 'false')
s += 'truefalse'
s += colorset('microTrendBackgroundFillColor', 'panel') + colorset('microTrendBackgroundColor', 'line')
acts = ['']
for i, (item, mn, mx, col) in enumerate(pens):
s += ('itemName[%d]'
'scaleMin[%d]%s'
'scaleMax[%d]%s'
'color[%d]'
'stroke[%d]'
) % (i, i, float(mn), i, float(mx), i, rgb(col), i)
acts.append(conn('microTrend.itemName[%d]' % i, item, 'ItemName'))
s += data(acts) + ''
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 ('%d%s@NULL'
'true%s'
'%s0'
'truetruetrue'
'@NULL@NULL%d'
'%s%sfalse'
'%s%s0'
'00false'
'falsefalse'
'false'
'false@NULL'
'@NULL@NULL'
'@NULL'
'@NULL'
''
''
'false0') % (
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 = ('' + self._id() +
''
'false'
'-1true'
'0'
'0'
''
'falsefalse@NULLfalse'
'00%d%dfalse'
'%s'
''
'false'
'@NULL'
'%sfalsetrue'
'true'
'truetrue'
'falsetrue'
'%s%s%s0.0'
''
'truefalse'
'falsefalse'
'falsefalsefalse'
'false500'
'0truetrue'
'false%s%s%s'
'truefalse'
'00'
'50true'
'%s'
'false%s'
''
'%s%s%s'
'%s%s'
''
''
''
''
'truetrue'
'0true%s'
''
'5truetrue'
'%s') % (
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 += ('%d%s%s'
'true%s'
'\n') % (i + 2, n, d, z)
head = ('\n\n \n'
' \n\n'
'%s\ntrue\n%d\n%d\n'
'%d\n'
'%s\ntrue\n%s\n%s\n%s\n\n'
''
'\n'
'\n'
'500\ntrue\nfalse\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 = '1Layer1\n'
body = '%d\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 \n\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())