- Group CIENG_OPERATOR dropped (08, 85, load.cmd, README): the live-data Start also works for a user in the factory group OPERATOR (tested 2026-10-06 as OPER), so EXECUTE_GENERAL_COMMANDS is not needed; 08b_user_df.qli gives CIENG the group OPERATOR - remove.cmd / README: open CIENG session note reworded - Generator README / cixml.py: arc angles run clockwise from 3 o'clock on desktop and web (fixed and connected; the earlier "mirrored on the desktop" note was wrong); rounding above 1 draws an ellipse on both clients - Tested on CI Server R1.03.00 2026-10-06: CIENG logon (empty password) and Start with group OPERATOR; test display with static and connected arcs on both clients Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
583 lines
37 KiB
Python
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.
|
|
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 = '<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())
|