- 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>
351 lines
22 KiB
Python
351 lines
22 KiB
Python
"""Build the CI-Eng-Sample demo displays (CI Server R1.03.00, HTML mode = desktop + web).
|
|
|
|
python build.py [<output folder>] (default: the displays folder, ..)
|
|
|
|
See README.md in this folder.
|
|
|
|
Displays: CIENG_Header, CIENG_Overview, CIENG_Control, CIENG_Trends, CIENG_Alarms, CIENG_System,
|
|
CIENG_AlarmBanner (frames of layout CIENG_Operator) and CIENG_TankFaceplate (layout CIENG_Popup).
|
|
"""
|
|
import os, sys
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from cixml import *
|
|
|
|
S = 'CI-Eng-Sample.'
|
|
LEVEL, INFLOW, VOLUME = S + 'TANK1.LEVEL', S + 'TANK1.INFLOW', S + 'TANK1.CALC.VOLUME'
|
|
LVL_COLORS = [(0, 'red'), (10, 'amber'), (20, 'teal'), (80, 'amber'), (90, 'red')] # LL/L/normal/H/HH
|
|
# live-data feed, installed by feed\install_feed.ps1 into a fixed folder outside CI Server
|
|
FEED_START = r'cmd /c "C:\Users\Public\CI-Eng-Sample\feed\cieng_feed_start.cmd"'
|
|
W, H, HD = 1880, 760, 680 # content displays (tab frames): 1880 x 760 (about 2.5:1, between the
|
|
# 16:9 and 16:10 content areas); coordinates below are designed for 680 high
|
|
FACEPLATE = act_open('CIENG_TankFaceplate', 'CIENG_Popup', 'default')
|
|
FACEPLATE_CLICK = act_open('CIENG_TankFaceplate', 'CIENG_Popup', 'default', event='Clicked b1')
|
|
|
|
|
|
def card(d, l, t, w, h, title, sub=None):
|
|
d.rect(l, t, w, h, fill='card', stroke='line', r=10)
|
|
d.text(l + 20, t + 12, w - 40, 22, title.upper(), 11, 'muted', bold=True)
|
|
if sub:
|
|
d.text(l + 20, t + 34, w - 40, 18, sub, 10, 'dim')
|
|
|
|
|
|
def kv(d, l, t, w, label, item, fmt, unit, size=22, color='text', extra=()):
|
|
d.text(l, t, w, 16, label, 10, 'muted')
|
|
d.number(l, t + 18, w - 50, 30, item, fmt, size, color, align='Left', extra=extra)
|
|
d.text(l + w - 48, t + 26, 48, 18, unit, 11, 'muted')
|
|
|
|
|
|
def level_color(prop='number.fillPaint'):
|
|
return conn(prop, LEVEL, transform=tr_threshold(LVL_COLORS))
|
|
|
|
|
|
def gauge(d, cx, cy, r, item, mn, mx, fmt, unit, label, color='teal', thresholds=None, extra=()):
|
|
d.ring_gauge(cx, cy, r, item, mn, mx, color, 12, thresholds)
|
|
d.number(cx - r + 14, cy - 22, 2 * r - 28, 36, item, fmt, 26, align='Center', extra=extra)
|
|
d.text(cx - 40, cy + 16, 80, 18, unit, 11, 'muted', align='Center')
|
|
d.text(cx - r, cy + r - 6, 2 * r, 20, label, 12, 'text', bold=True, align='Center')
|
|
|
|
|
|
def build_header():
|
|
d = Display('CIENG_Header', 1920, 96, 'Header', bg='panel')
|
|
d.rect(0, 0, 1920, 96, fill='panel')
|
|
d.line(0, 95, 1920, 95, 'line', 1)
|
|
d.rect(20, 18, 60, 60, fill='blue', r=14)
|
|
d.text(20, 34, 60, 28, 'CI', 22, 'white', bold=True, align='Center')
|
|
d.text(96, 18, 380, 30, 'CI-Eng-Sample', 22, 'text', bold=True)
|
|
d.text(96, 52, 400, 20, 'Operations dashboard | Tank farm demo', 12, 'muted')
|
|
# KPI chips
|
|
chips = [(520, 'TANK LEVEL', LEVEL, '999.9', '%', [level_color()]),
|
|
(700, 'INFLOW', INFLOW, '9999.9', 'm3/h', []),
|
|
(880, 'VOLUME', VOLUME, '9999.9', 'm3', [])]
|
|
for x, label, item, fmt, unit, extra in chips:
|
|
d.rect(x, 14, 168, 68, fill='card', stroke='line', r=8)
|
|
d.text(x + 14, 22, 140, 16, label, 10, 'muted', bold=True)
|
|
d.number(x + 14, 40, 100, 32, item, fmt, 22, align='Left', extra=extra)
|
|
d.text(x + 118, 50, 46, 18, unit, 11, 'muted')
|
|
x = 1060
|
|
d.rect(x, 14, 168, 68, fill='card', stroke='line', r=8)
|
|
d.text(x + 14, 22, 140, 16, 'PUMP 1', 10, 'muted', bold=True)
|
|
d.lamp(x + 14, 48, 18, S + 'PUMP1.RUNNING')
|
|
for txt, col, on in (('RUNNING', 'green', True), ('STOPPED', 'muted', False)):
|
|
d.text(x + 40, 44, 120, 24, txt, 14, col, bold=True)
|
|
d.parts[-1] = d.parts[-1].replace('</text>', data([conn('text.visible', S + 'PUMP1.RUNNING',
|
|
transform=tr_threshold([(False, not on), (True, on)], 'Boolean', 'Boolean'))]) + '</text>')
|
|
x = 1240
|
|
d.rect(x, 14, 168, 68, fill='card', stroke='line', r=8)
|
|
d.text(x + 14, 22, 140, 16, 'COMMUNICATION', 10, 'muted', bold=True)
|
|
d.lamp(x + 14, 50, 14, S + 'PLC1.COMM_OK', off='red')
|
|
d.text(x + 34, 46, 50, 20, 'PLC1', 12, 'text')
|
|
d.lamp(x + 90, 50, 14, S + 'UA.COMM_OK', off='red')
|
|
d.text(x + 110, 46, 50, 20, 'OPC UA', 12, 'text')
|
|
# live data start/stop
|
|
x = 1424
|
|
d.rect(x, 14, 250, 68, fill='card', stroke='line', r=8)
|
|
d.text(x + 14, 22, 120, 16, 'LIVE DATA', 10, 'muted', bold=True)
|
|
d.lamp(x + 112, 23, 12, S + 'DEMO.FEED_ON')
|
|
d.button(x + 14, 44, 104, 30, 'Start', [act_set(S + 'DEMO.FEED_ON', 1), act_cmd(FEED_START)], fg='white', bg='green',
|
|
tip='Start the live-data feed (cieng_feed.ps1) on the server')
|
|
d.button(x + 130, 44, 104, 30, 'Stop', [act_set(S + 'DEMO.FEED_ON', 0)], fg='text', bg='card2',
|
|
tip='Stop the feed (ends within 5 s)')
|
|
# faceplate + clock
|
|
d.button(1688, 14, 92, 68, 'Tank 1', [FACEPLATE], fg='white', bg='blue', tip='Open the Tank 1 faceplate popup')
|
|
d.text(1792, 22, 120, 16, 'LAST UPDATE', 10, 'muted', bold=True)
|
|
d.time_text(1792, 44, 122, 28, S + 'DEMO.COUNTER', 9)
|
|
return d
|
|
|
|
|
|
def build_overview():
|
|
d = Display('CIENG_Overview', W, H, 'Overview', design_h=HD)
|
|
d.rect(0, 0, W, HD, fill='bg')
|
|
card(d, 10, 10, 1110, 660, 'Process - Tank 1', 'Click the tank for its faceplate')
|
|
# inflow pipe + flow indicator
|
|
d.rect(60, 330, 380, 16, fill='track')
|
|
d.rect(60, 333, 380, 10, fill='teal', actions=[conn('rectangle.visible', INFLOW,
|
|
transform=tr_threshold([(0, False), (5, True)], 'Double', 'Boolean'))])
|
|
d.text(60, 250, 200, 16, 'INFLOW', 10, 'muted', bold=True)
|
|
d.number(60, 268, 150, 40, INFLOW, '9999.9', 30, align='Left')
|
|
d.text(214, 282, 60, 20, 'm3/h', 12, 'muted')
|
|
# tank
|
|
d.rect(440, 100, 240, 480, fill='panel', stroke='line', sw=2, r=18, actions=[FACEPLATE_CLICK],
|
|
tip='Tank 1 - click for the faceplate')
|
|
d.rect(450, 110, 220, 460, fill='blue', r=12, fill_amount=0.5, fill_dir='FillDirectionUp',
|
|
actions=[conn('rectangle.fillAmount', LEVEL, transform=tr_linear(0, 100, 0, 1)),
|
|
conn('rectangle.fillPaint', LEVEL, transform=tr_threshold(
|
|
[(0, (185, 28, 28)), (10, (180, 83, 9)), (20, (37, 99, 235)), (80, (180, 83, 9)), (90, (185, 28, 28))])),
|
|
FACEPLATE_CLICK])
|
|
d.text(450, 60, 220, 28, 'TANK 1', 16, 'text', bold=True, align='Center')
|
|
d.number(460, 300, 200, 50, LEVEL, '999.9', 40, 'white', align='Center')
|
|
d.text(460, 352, 200, 20, '% level', 12, 'text', align='Center')
|
|
for pct, lab, col in ((90, 'HH 90', 'red'), (80, 'H 80', 'amber'), (20, 'L 20', 'amber'), (10, 'LL 10', 'red')):
|
|
y = 570 - pct * 4.6
|
|
d.line(682, y, 700, y, col, 2)
|
|
d.text(706, y - 9, 60, 18, lab, 10, col, bold=True)
|
|
d.button(470, 594, 180, 32, 'Open faceplate', [FACEPLATE], fg='white', bg='blue')
|
|
# outlet pipe + pump
|
|
d.rect(680, 532, 150, 16, fill='track')
|
|
d.rect(680, 535, 150, 10, fill='teal', actions=[conn('rectangle.visible', S + 'PUMP1.RUNNING')])
|
|
d.ellipse(830, 495, 90, 90, fill='off', stroke='line', sw=2, actions=[
|
|
conn('ellipse.fillPaint', S + 'PUMP1.RUNNING', transform=tr_threshold([(False, 'off'), (True, 'green')], 'Boolean'))])
|
|
d.text(830, 528, 90, 24, 'P1', 16, 'white', bold=True, align='Center')
|
|
d.rect(920, 532, 170, 16, fill='track')
|
|
d.rect(920, 535, 170, 10, fill='teal', actions=[conn('rectangle.visible', S + 'PUMP1.RUNNING')])
|
|
d.text(800, 600, 150, 18, 'PUMP 1', 11, 'muted', bold=True, align='Center')
|
|
for txt, col, val in (('AUTO', 'teal', 1), ('MANUAL', 'amber', 0)):
|
|
d.text(800, 620, 150, 20, txt, 13, col, bold=True, align='Center')
|
|
d.parts[-1] = d.parts[-1].replace('</text>', data([conn('text.visible', S + 'PUMP1.MODE', transform=tr_threshold(
|
|
[(0, val == 0), (1, val == 1)], 'Double', 'Boolean'))]) + '</text>')
|
|
# info column
|
|
x = 860
|
|
d.rect(x, 90, 230, 330, fill='panel', stroke='line', r=8)
|
|
kv(d, x + 16, 104, 200, 'VOLUME (TANK1.CALC)', VOLUME, '9999.9', 'm3')
|
|
kv(d, x + 16, 168, 200, 'FLOW PER SECOND (JFLOW)', S + 'TANK1.JFLOW.sPerMin', '999.99', 'm3/s')
|
|
kv(d, x + 16, 232, 200, 'LEVEL MAXIMUM', S + 'TANK1.LEVEL.MAX', '999.9', '%')
|
|
kv(d, x + 16, 296, 200, 'COUNTER (DEMO.INCR1)', S + 'DEMO.COUNTER', '999', '')
|
|
d.databar(x + 16, 352, 198, 10, S + 'DEMO.COUNTER', 0, 100, 'violet', 'track', 'Right')
|
|
d.text(x + 16, 376, 200, 16, 'PUMP RUN TIME', 10, 'muted')
|
|
d.number(x + 16, 392, 150, 24, S + 'PUMP1.RUNNING.RUN_SECONDS', '9999999', 16, align='Left')
|
|
d.text(x + 170, 396, 40, 18, 's', 11, 'muted')
|
|
# gauges
|
|
card(d, 1130, 10, 740, 330, 'Key figures')
|
|
gauge(d, 1255, 175, 95, LEVEL, 0, 100, '999.9', '%', 'Level', thresholds=LVL_COLORS, extra=[level_color()])
|
|
gauge(d, 1500, 175, 95, INFLOW, 0, 500, '9999', 'm3/h', 'Inflow', 'violet')
|
|
gauge(d, 1745, 175, 95, VOLUME, 0, 200, '999.9', 'm3', 'Volume', 'blue')
|
|
card(d, 1130, 350, 740, 320, 'Last 10 minutes', 'Micro trend: level (teal, 0-100 %) and inflow (violet, 0-500 m3/h)')
|
|
d.micro_trend(1150, 410, 700, 240, [(LEVEL, 0, 100, 'teal'), (INFLOW, 0, 500, 'violet')])
|
|
return d
|
|
|
|
|
|
def build_control():
|
|
d = Display('CIENG_Control', W, H, 'Process control', design_h=HD)
|
|
d.rect(0, 0, W, HD, fill='bg')
|
|
# setpoint -> Modbus loopback
|
|
card(d, 10, 10, 925, 325, 'Setpoint -> PLC1 (Modbus loopback)', 'Slider, spinner and input field write PLC1.SETPOINT; READBACK returns through Modbus')
|
|
d.slider(40, 70, 600, 64, S + 'PLC1.SETPOINT', 0, 100, 10, 5)
|
|
d.text(40, 150, 140, 16, 'SPINNER', 10, 'muted', bold=True)
|
|
d.spinner(40, 170, 140, 36, S + 'PLC1.SETPOINT', 0, 100, 5)
|
|
d.text(200, 150, 140, 16, 'INPUT FIELD', 10, 'muted', bold=True)
|
|
d.number_field(200, 170, 140, 36, S + 'PLC1.SETPOINT', '999', 16)
|
|
d.text(360, 150, 160, 16, 'SETPOINT', 10, 'muted', bold=True)
|
|
d.number(360, 168, 120, 40, S + 'PLC1.SETPOINT', '999', 28, 'blue', align='Left')
|
|
d.text(40, 226, 300, 16, 'READBACK (data bar + scale)', 10, 'muted', bold=True)
|
|
d.databar(40, 248, 600, 18, S + 'PLC1.READBACK', 0, 100, 'blue', 'track', 'Right')
|
|
d.rect_scale(40, 268, 600, 36, 0, 100, 10, 5, vertical=False)
|
|
gauge(d, 790, 175, 95, S + 'PLC1.READBACK', 0, 100, '999', '', 'Readback', 'blue')
|
|
d.lamp(700, 30, 12, S + 'PLC1.COMM_OK', off='red')
|
|
d.text(718, 26, 200, 18, 'station CIENG_PLC', 11, 'muted')
|
|
# digital command
|
|
card(d, 945, 10, 925, 325, 'Digital command -> PLC1.CMD', 'Toggle and push buttons write the coil; FEEDBACK is read back from the input bit')
|
|
d.toggle(975, 80, 200, 48, 'CMD (toggle)', S + 'PLC1.CMD', 14)
|
|
d.button(975, 150, 95, 40, 'Set ON', [act_set(S + 'PLC1.CMD', 1)], fg='white', bg='green')
|
|
d.button(1080, 150, 95, 40, 'Set OFF', [act_set(S + 'PLC1.CMD', 0)], fg='text', bg='card2')
|
|
d.ellipse(1300, 80, 120, 120, fill='off', stroke='line', sw=3, actions=[
|
|
conn('ellipse.fillPaint', S + 'PLC1.FEEDBACK', transform=tr_threshold([(False, 'off'), (True, 'green')], 'Boolean'))])
|
|
d.text(1300, 128, 120, 24, 'FEEDBACK', 13, 'white', bold=True, align='Center')
|
|
d.text(1460, 90, 380, 16, 'LAST CHANGE OF FEEDBACK', 10, 'muted', bold=True)
|
|
d.datetime_field(1460, 112, 300, 30, S + 'PLC1.FEEDBACK', 'ItemUpdTime', 13) # date/time field (editable on the web)
|
|
d.text(1460, 160, 380, 16, 'STATUS', 10, 'muted', bold=True)
|
|
d.text(1460, 180, 380, 24, 'NORMAL', 16, 'text', bold=True)
|
|
d.parts[-1] = d.parts[-1].replace('</text>', data([conn('text.value', S + 'PLC1.FEEDBACK', 'ItemStatusMnemonic')]) + '</text>')
|
|
# pump
|
|
card(d, 10, 345, 925, 325, 'Pump 1', 'Combo box, buttons and check box write PUMP1.MODE / PUMP1.RUNNING (mode changes are audited)')
|
|
d.text(40, 400, 160, 16, 'MODE (COMBO BOX)', 10, 'muted', bold=True)
|
|
d.combo(40, 420, 200, 34, S + 'PUMP1.MODE', '0;Manual;1;Auto', 14)
|
|
d.button(260, 420, 110, 34, 'Manual', [act_set(S + 'PUMP1.MODE', 0)], fg='text', bg='card2')
|
|
d.button(380, 420, 110, 34, 'Auto', [act_set(S + 'PUMP1.MODE', 1)], fg='white', bg='blue')
|
|
d.text(40, 478, 300, 16, 'RUN COMMAND (CHECK BOX, MANUAL MODE)', 10, 'muted', bold=True)
|
|
d.checkbox(40, 498, 240, 32, 'Pump running', S + 'PUMP1.RUNNING', 14)
|
|
d.text(40, 552, 300, 16, 'DESCRIPTION (TEXT FIELD)', 10, 'muted', bold=True)
|
|
d.text_field(40, 572, 450, 30, S + 'PUMP1.MODE', 'ItemDesc', 13)
|
|
d.ellipse(600, 420, 110, 110, fill='off', stroke='line', sw=3, actions=[
|
|
conn('ellipse.fillPaint', S + 'PUMP1.RUNNING', transform=tr_threshold([(False, 'off'), (True, 'green')], 'Boolean'))])
|
|
d.text(600, 462, 110, 24, 'P1', 18, 'white', bold=True, align='Center')
|
|
d.text(560, 548, 220, 16, 'RUN TIME (INTEGRATOR)', 10, 'muted', bold=True, align='Center')
|
|
d.number(560, 566, 190, 34, S + 'PUMP1.RUNNING.RUN_SECONDS', '9999999', 24, align='Right')
|
|
d.text(754, 576, 30, 18, 's', 12, 'muted')
|
|
# alarm test
|
|
card(d, 945, 345, 925, 325, 'Tank 1 - alarm test', 'Stop live data first, then drive TANK1.LEVEL across its limits (H 80 / HH 90 / L 20 / LL 10)')
|
|
d.slider(975, 410, 560, 64, LEVEL, 0, 100, 10, 5)
|
|
for i, (lab, v, col, fg) in enumerate((('Low-Low 5', 5, 'red', 'white'), ('Low 15', 15, 'amber', 'white'),
|
|
('Normal 50', 50, 'teal', 'white'), ('High 85', 85, 'amber', 'white'),
|
|
('High-High 95', 95, 'red', 'white'))):
|
|
d.button(975 + i * 114, 500, 106, 38, lab, [act_set(LEVEL, v)], fg=fg, bg=col)
|
|
d.text(975, 566, 200, 16, 'LEVEL', 10, 'muted', bold=True)
|
|
d.number(975, 584, 160, 50, LEVEL, '999.9', 38, align='Left', extra=[level_color()])
|
|
d.text(1180, 566, 300, 16, 'ITEM STATUS', 10, 'muted', bold=True)
|
|
d.text(1180, 590, 300, 34, 'NORMAL', 22, 'text', bold=True)
|
|
d.parts[-1] = d.parts[-1].replace('</text>', data([conn('text.value', LEVEL, 'ItemStatusMnemonic'),
|
|
conn('text.fillPaint', LEVEL, transform=tr_threshold(LVL_COLORS))]) + '</text>')
|
|
d.ring_gauge(1720, 520, 90, LEVEL, 0, 100, 'teal', 14, LVL_COLORS)
|
|
d.circ_scale(1720, 520, 120, 0, 100, 20, 5)
|
|
d.number(1660, 500, 120, 40, LEVEL, '999.9', 26, align='Center')
|
|
return d
|
|
|
|
|
|
def build_trends():
|
|
d = Display('CIENG_Trends', W, H, 'Trends', design_h=HD)
|
|
d.rect(0, 0, W, HD, fill='bg')
|
|
d.rect(10, 10, 1380, 660, fill='panel', stroke='line', r=10)
|
|
d.trend(20, 20, 1360, 640, [(LEVEL, 'teal', 0, 100, 'Tank 1 level (%)'),
|
|
(INFLOW, 'violet', 0, 500, 'Tank 1 inflow (m3/h)')], 10, 'Tank 1 - level and inflow',
|
|
web_spares=False) # user choice: hairline shows 2 values; the web main trend then draws no line
|
|
for i, (title, item, mn, mx, col, fmt, unit) in enumerate((
|
|
('Level', LEVEL, 0, 100, 'teal', '999.9', '%'), ('Inflow', INFLOW, 0, 500, 'violet', '9999.9', 'm3/h'),
|
|
('CPU load', S + 'SYSTEM.CPU_LOAD', 0, 100, 'blue', '999.9', '%'))):
|
|
t = 10 + i * 223
|
|
card(d, 1400, t, 470, 213, title)
|
|
d.number(1660, t + 8, 150, 30, item, fmt, 20, col, align='Right')
|
|
d.text(1814, t + 14, 50, 18, unit, 11, 'muted')
|
|
d.micro_trend(1420, t + 48, 430, 150, [(item, mn, mx, col)], history=(i < 2)) # CPU_LOAD has no history
|
|
return d
|
|
|
|
|
|
def build_alarms():
|
|
d = Display('CIENG_Alarms', W, H, 'Alarms', design_h=HD)
|
|
d.rect(0, 0, W, HD, fill='bg')
|
|
card(d, 10, 10, 1860, 390, 'Current alarms', 'All areas - acknowledge with the check box or right-click')
|
|
d.alarm_overview(20, 60, 1840, 330, size=13)
|
|
card(d, 10, 410, 1860, 260, 'Alarm history - Tank 1', 'Historical overview filtered on alarm selection area CIENG_TANK1')
|
|
d.alarm_overview(20, 460, 1840, 200, 'HistoricalAlarms', asa='CIENG_TANK1', size=13)
|
|
return d
|
|
|
|
|
|
def build_system():
|
|
d = Display('CIENG_System', W, H, 'System', design_h=HD)
|
|
d.rect(0, 0, W, HD, fill='bg')
|
|
card(d, 10, 10, 600, 320, 'Server health', 'Real values of this PC, written by the live-data feed')
|
|
th = [(0, 'teal'), (80, 'amber'), (90, 'red')]
|
|
gauge(d, 160, 180, 100, S + 'SYSTEM.CPU_LOAD', 0, 100, '999.9', '%', 'CPU load', thresholds=th)
|
|
gauge(d, 450, 180, 100, S + 'SYSTEM.DISK_C', 0, 100, '999.9', '%', 'Disk C: used', 'blue', thresholds=th)
|
|
card(d, 620, 10, 600, 320, 'Communication and processes')
|
|
rows = ((S + 'PLC1.COMM_OK', 'Modbus station CIENG_PLC', 'Boolean', None),
|
|
(S + 'UA.COMM_OK', 'OPC UA client station CIENG_UA', 'Boolean', None),
|
|
(S + 'UA.SRV_STATE', 'OPC UA server state (0 = running)', 'Double', [(0, 'green'), (0.5, 'red')]),
|
|
(S + 'SYSTEM.PING_LOCALHOST', 'localhost reachable', 'Double', [(0, 'red'), (0.5, 'green')]),
|
|
(S + 'SYSTEM.PROC_ALM', 'ALARM process (alm) running', 'Double', [(0, 'red'), (0.5, 'green')]))
|
|
for i, (item, lab, inp, tbl) in enumerate(rows):
|
|
y = 60 + i * 52
|
|
d.rect(640, y, 560, 44, fill='panel', r=6)
|
|
if inp == 'Boolean':
|
|
d.lamp(656, y + 13, 18, item, off='red')
|
|
else:
|
|
d.lamp(656, y + 13, 18, item, inp='Double', table=tbl)
|
|
d.text(688, y + 12, 340, 20, lab, 13, 'text')
|
|
d.text(1040, y + 12, 150, 20, 'NORMAL', 12, 'muted', align='Right')
|
|
d.parts[-1] = d.parts[-1].replace('</text>', data([conn('text.value', item, 'ItemStatusMnemonic')]) + '</text>')
|
|
card(d, 1230, 10, 640, 320, 'OPC UA loopback', 'TANK1.LEVEL read back through CI Server\'s own OPC UA server')
|
|
for i, (lab, item, col) in enumerate((('TANK1.LEVEL', LEVEL, 'teal'), ('UA.LEVEL_COPY', S + 'UA.LEVEL_COPY', 'blue'))):
|
|
x = 1260 + i * 300
|
|
d.text(x, 70, 260, 16, lab, 10, 'muted', bold=True)
|
|
d.number(x, 90, 200, 44, item, '999.9', 32, col, align='Left')
|
|
d.databar(x, 150, 260, 18, item, 0, 100, col, 'track', 'Right')
|
|
d.text(x, 182, 260, 16, 'LAST UPDATE', 10, 'muted', bold=True)
|
|
d.time_text(x, 202, 260, 28, item, 13)
|
|
d.rect_scale(1260, 250, 560, 34, 0, 100, 10, 5, vertical=False)
|
|
card(d, 10, 340, 1860, 330, 'Reports', 'Report component: select CIENG_TANK1 or CIENG_HTML, generate and view')
|
|
d.report(20, 390, 1840, 270)
|
|
return d
|
|
|
|
|
|
def build_banner():
|
|
d = Display('CIENG_AlarmBanner', 1920, 96, 'Alarm banner', bg='panel')
|
|
# Desktop: frames inside a <tab> ignore their zoom attribute; the layout command with a zoom argument
|
|
# applies it. Only ONE command can run at a time (more give "IllegalStateException: A command is
|
|
# already being executed"), so only the Overview tab is fitted here; the toolbar / menu / F-keys
|
|
# open the other tabs with fitDisplay. Web: the plain open action (web ignores ACTION commands).
|
|
d.rect(0, 0, 1920, 96, fill='panel', actions=[
|
|
act_open('CIENG_Overview', 'CIENG_Operator', 'ovw', event='On entry'),
|
|
act_cmd('ACTION navigate to ovw CIENG_Overview stretchDisplay', server=False, event='On entry')])
|
|
d.line(0, 0, 1920, 0, 'line', 1)
|
|
d.rect(10, 7, 170, 82, fill='card', stroke='line', r=8)
|
|
d.text(24, 13, 70, 18, 'ALARMS', 12, 'muted', bold=True)
|
|
d.ellipse(96, 14, 16, 16, fill='off', actions=[conn('ellipse.fillPaint', LEVEL, transform=tr_threshold(
|
|
[(0, 'red'), (10, 'amber'), (20, 'green'), (80, 'amber'), (90, 'red')]))])
|
|
d.text(118, 13, 60, 18, 'Tank 1', 12, 'text')
|
|
d.button(24, 44, 142, 34, 'Alarm list', [act_open('CIENG_Alarms', 'CIENG_Operator', 'alm')], fg='white', bg='blue', size=13)
|
|
# 4 rows of 20 px (14 px font, web) + border: no empty white area below the rows when 4+ alarms
|
|
d.alarm_overview(190, 7, 1722, 82, size=14, banner=True)
|
|
return d
|
|
|
|
|
|
def build_faceplate():
|
|
d = Display('CIENG_TankFaceplate', 420, 600, 'Tank 1 faceplate', bg='card')
|
|
d.rect(0, 0, 420, 600, fill='card')
|
|
d.rect(0, 0, 420, 52, fill='card2')
|
|
d.text(20, 14, 300, 24, 'TANK 1 | LEVEL', 15, 'text', bold=True)
|
|
d.button(370, 10, 36, 32, 'X', [act_close()], fg='text', bg='card', tip='Close')
|
|
d.text(20, 64, 380, 18, 'Tank 1 level (%)', 11, 'muted')
|
|
d.parts[-1] = d.parts[-1].replace('</text>', data([conn('text.value', LEVEL, 'ItemDesc')]) + '</text>')
|
|
d.number(20, 86, 200, 56, LEVEL, '999.9', 44, align='Left', extra=[level_color()])
|
|
d.text(224, 106, 30, 24, '%', 16, 'muted')
|
|
d.text(270, 92, 130, 16, 'STATUS', 10, 'muted', bold=True, align='Right')
|
|
d.text(250, 110, 150, 26, 'NORMAL', 16, 'text', bold=True, align='Right')
|
|
d.parts[-1] = d.parts[-1].replace('</text>', data([conn('text.value', LEVEL, 'ItemStatusMnemonic'),
|
|
conn('text.fillPaint', LEVEL, transform=tr_threshold(LVL_COLORS))]) + '</text>')
|
|
d.databar(40, 160, 60, 280, LEVEL, 0, 100, 'blue', 'track', 'Up')
|
|
d.rect_scale(104, 160, 44, 280, 0, 100, 20, 5)
|
|
d.text(190, 160, 210, 16, 'ALARM LIMITS', 10, 'muted', bold=True)
|
|
for i, (lab, attr, col) in enumerate((('High-High', 'ItemHHL', 'red'), ('High', 'ItemHL', 'amber'),
|
|
('Low', 'ItemLL', 'amber'), ('Low-Low', 'ItemLLL', 'red'))):
|
|
y = 186 + i * 40
|
|
d.rect(190, y, 210, 34, fill='panel', r=6)
|
|
d.rect(190, y, 6, 34, fill=col)
|
|
d.text(206, y + 8, 100, 18, lab, 12, 'text')
|
|
d.number(300, y + 4, 90, 26, LEVEL, '999.9', 16, col, attr=attr)
|
|
d.text(190, 352, 210, 16, 'MAXIMUM SEEN', 10, 'muted', bold=True)
|
|
d.number(190, 370, 140, 32, S + 'TANK1.LEVEL.MAX', '999.9', 22, align='Left')
|
|
d.text(190, 412, 210, 16, 'LAST UPDATE', 10, 'muted', bold=True)
|
|
d.time_text(190, 430, 210, 24, LEVEL, 11)
|
|
d.micro_trend(20, 460, 380, 80, [(LEVEL, 0, 100, 'teal')])
|
|
d.button(20, 552, 120, 34, 'Trends', [act_open('CIENG_Trends', 'CIENG_Operator', 'trd')], fg='text', bg='card2')
|
|
d.button(150, 552, 120, 34, 'Alarms', [act_open('CIENG_Alarms', 'CIENG_Operator', 'alm')], fg='text', bg='card2')
|
|
d.button(280, 552, 120, 34, 'Close', [act_close()], fg='white', bg='blue')
|
|
return d
|
|
|
|
|
|
if __name__ == '__main__':
|
|
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
|
|
for b in (build_header, build_overview, build_control, build_trends, build_alarms, build_system, build_banner,
|
|
build_faceplate):
|
|
disp = b()
|
|
disp.save(out)
|
|
print(disp.name, len(disp.parts), 'components')
|