"""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.
Arc angles run clockwise from 3 o'clock on both clients (tested 2026-10-06, fixed and
connected angles): 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())