Operator screen
Connectivity
Two data sessions, a feed that stops, and how stale data is shown.
index.htm as it is written with the smart-industrial package; industrial-demos.css is the screens' shared layout
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>Connectivity - Smart.Industrial</title>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.default.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial-elements.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial.css" />
<link rel="stylesheet" type="text/css" href="node_modules/smart-industrial/source/styles/smart.industrial-dark.css" />
<link rel="stylesheet" type="text/css" href="industrial-demos.css" />
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
<body class="viewport" theme="industrial-dark">
<div class="hmi-screen">
<header class="hmi-header">
<div class="hmi-identity">
<span class="hmi-plant">CONNECTIVITY</span>
<span class="hmi-area">Tags into components, with quality and time intact</span>
</div>
<div class="hmi-header-status">
<span class="hmi-conn">
<span class="hmi-conn-dot" id="loopDot"></span>
<span id="loopLabel">loop · connecting</span>
</span>
<span class="hmi-conn">
<span class="hmi-conn-dot" id="fieldDot"></span>
<span id="fieldLabel">field · connecting</span>
</span>
<button class="hmi-theme-toggle" id="themeToggle" type="button">Light</button>
</div>
</header>
<section class="cx-note" aria-label="What this screen demonstrates">
<p>
Every value on this screen arrives as a tag through <code>Smart.Industrial.Connect</code> and is routed into a component property by <code>bind()</code> or <code>stream()</code>. The screen runs without a server: two sessions are open, one on the <code>simulator</code> adapter that ships with the package and one on the <code>manual</code> adapter, which is used when the page already has its own MQTT or WebSocket client.
</p>
<p class="cx-note-strong">
Use the buttons below to stop a feed. The reading does not keep its last good value. Its quality changes to <b>stale</b>, because a tag that has stopped arriving has an unknown value.
</p>
</section>
<section class="cx-controls" aria-label="Feed controls">
<button class="cx-button cx-button-danger" id="cutFeed" type="button">Cut FIC-101 feed</button>
<button class="cx-button" id="restoreFeed" type="button" disabled="disabled">Restore feed</button>
<button class="cx-button" id="degradeQuality" type="button">Report FIC-101 as bad quality</button>
<span class="cx-hint" id="feedHint">Feed live · stale after 3 s of silence</span>
</section>
<section class="cx-tiles" aria-label="Bound status tiles">
<smart-status-tile id="tileFlow" label="Feed flow" unit="m³/h" precision-digits="1"></smart-status-tile>
<smart-status-tile id="tilePressure" label="Header pressure" unit="bar" precision-digits="2"></smart-status-tile>
<smart-status-tile id="tileTemp" label="Jacket temperature" unit="°C" precision-digits="1"></smart-status-tile>
</section>
<section class="cx-main">
<div class="cx-panel">
<h2 class="cx-panel-title">Bound loops</h2>
<p class="cx-panel-sub">
<code>Connect.bind(element, { processValue: 'FIC-101.PV', setpoint: 'FIC-101.SP' })</code>
</p>
<div class="cx-faceplates">
<smart-faceplate id="loopFlow" tag="FIC-101" description="Feed flow control"
unit="m³/h" min="0" max="120" precision-digits="1"></smart-faceplate>
<smart-faceplate id="loopPressure" tag="PIC-102" description="Header pressure"
unit="bar" min="0" max="10" precision-digits="2"></smart-faceplate>
</div>
</div>
<div class="cx-panel">
<h2 class="cx-panel-title">Streamed trend</h2>
<p class="cx-panel-sub">
<code>Connect.stream(chart, { flow: { tag: 'FIC-101.PV', session: loop }, pressure: { tag: 'PIC-102.PV', session: field } })</code> combines tags from both sessions into one record per instant, stamped with the newest source time.
</p>
<smart-strip-chart id="trend" show-legend show-digital-display></smart-strip-chart>
</div>
</section>
<section class="cx-panel cx-wide" aria-label="Sample inspector">
<h2 class="cx-panel-title">Received samples</h2>
<p class="cx-panel-sub">
The last sample of each tag as the binding received it. A sample carries a value, a quality and a time. When the source sends no timestamp, the receipt time is used and marked as local.
</p>
<table class="cx-samples">
<thead>
<tr>
<th scope="col">Tag</th>
<th scope="col">Value</th>
<th scope="col">Quality</th>
<th scope="col">Timestamp</th>
<th scope="col">Source</th>
</tr>
</thead>
<tbody id="sampleRows"></tbody>
</table>
</section>
</div>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.industrial.connect.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.faceplate.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.statustile.js"></script>
<script type="module" src="node_modules/smart-industrial/source/modules/smart.stripchart.js"></script>
<script type="module" src="index.js"></script>
<script>
/* Shown once every element has rendered, so nothing moves on the way in (styles/demos.css). */
addEventListener('load', function () {
var all = Array.prototype.slice.call(document.querySelectorAll('*')),
ready = function () { document.body.classList.add('ready'); };
Promise.all(all.map(function (e) { return e.localName.indexOf('smart-') === 0 ? customElements.whenDefined(e.localName) : null; })).then(function () {
return Promise.all(all.filter(function (e) { return typeof e.whenRendered === 'function' && !e.isRendered; }).map(function (e) { return e.whenRendered(); }));
}).then(function () { requestAnimationFrame(function () { requestAnimationFrame(ready); }); }, ready);
});
</script>
<script>
/* The site's light/dark switch is the reader's choice: a screen opened from the light site
in a new tab opens light too. Framed, the site's page sets the theme itself. */
addEventListener('load', function () {
var light = false,
toggle = document.querySelector('.hmi-theme-toggle');
try { light = window.top === window && localStorage.getItem('smart-industrial-dark') === 'false'; } catch (e) { }
/* after the screen's own onload, which is where its button starts listening */
if (light && toggle) { setTimeout(function () { if (document.body.getAttribute('theme') === 'industrial-dark') { toggle.click(); } }, 0); }
});
</script>
</body>
</html>
index.js
/**
* Connectivity - Smart.Industrial.Connect end to end.
*
* Two sessions, deliberately:
*
* field the shipped 'simulator' adapter, which needs no server and no code. This is what
* an evaluation runs on, including on an air-gapped site.
* loop the 'manual' adapter, where the page owns the transport. This is the realistic
* case - a customer already has an MQTT or WebSocket client - and it is also the
* only way this demo can cut a feed on demand to show what happens next.
*
* Two sessions is not contrivance for the demo's sake. A test station genuinely has a plant
* broker and an instrument stream, and a layer with one implicit global connection makes
* that ordinary case need a workaround on day one.
*/
window.onload = function () {
const Connect = window.Smart.Industrial.Connect;
/* ---------------------------------------------------------------- field session */
const field = Connect.open('simulator', {
interval: 250,
tags: {
'TIC-204.PV': { min: 58, max: 74, period: 41000, noise: 0.02 },
'PIC-102.PV': { min: 3.2, max: 7.4, period: 23000, noise: 0.03 },
'PIC-102.SP': { kind: 'step', min: 5.5, max: 5.5, noise: 0 },
'PIC-102.OP': { min: 30, max: 70, period: 23000, noise: 0.01 }
}
});
/* ---------------------------------------------------------------- loop session */
/* The generator the manual session drives. Held here so the demo can stop it, which is
the whole point of the screen: a feed that stops is not a value that holds. */
let emitSample = null,
feedTimer = null,
reportedQuality = 'good',
started = Date.now();
const loop = Connect.open('manual', {
attach: function (emit) {
emitSample = emit;
},
/* A write the operator makes has to go somewhere. Here it moves the simulated
setpoint; against a real gateway it would be the gateway's write. */
write: function (tag, value) {
if (tag === 'FIC-101.SP') {
setpoint = Number(value);
}
return true;
}
});
let setpoint = 78;
function runFeed() {
feedTimer = setInterval(function () {
const at = Date.now(),
phase = ((at - started) % 37000) / 37000,
flow = 74 + 18 * Math.sin(phase * Math.PI * 2) + (Math.random() - 0.5) * 1.5;
emitSample({ tag: 'FIC-101.PV', value: flow, quality: reportedQuality, timestamp: at });
emitSample({ tag: 'FIC-101.SP', value: setpoint, quality: 'good', timestamp: at });
emitSample({ tag: 'FIC-101.OP', value: 40 + 25 * Math.sin(phase * Math.PI * 2 + 0.4),
quality: reportedQuality, timestamp: at });
}, 250);
}
runFeed();
/* ---------------------------------------------------------------- bindings */
const loopFlow = document.getElementById('loopFlow'),
loopPressure = document.getElementById('loopPressure'),
trend = document.getElementById('trend');
/* staleAfter is the point of the screen. Three seconds of silence on a 4 Hz tag is
twelve missed samples - long past the point where the last reading still describes
the plant. Set it from your scan rate, not from taste. */
Connect.bind(loopFlow, {
processValue: { tag: 'FIC-101.PV', staleAfter: 3000 },
setpoint: 'FIC-101.SP',
output: 'FIC-101.OP'
}, loop);
Connect.bind(loopPressure, {
processValue: { tag: 'PIC-102.PV', staleAfter: 3000 },
setpoint: 'PIC-102.SP',
output: 'PIC-102.OP'
}, field);
/* Deadband on the tiles: a tile is read across a room and does not need the last digit
of a noisy sensor, and every assignment is a render. No staleAfter here: staleness is
shown through a quality property, and a status tile has none - the faceplate above,
bound to the same tag, is where a silent FIC-101 shows as stale. */
Connect.bind(document.getElementById('tileFlow'), {
value: { tag: 'FIC-101.PV', deadband: 0.2 }
}, loop);
Connect.bind(document.getElementById('tilePressure'), {
value: { tag: 'PIC-102.PV', deadband: 0.02 }
}, field);
Connect.bind(document.getElementById('tileTemp'), {
value: { tag: 'TIC-204.PV', deadband: 0.1 }
}, field);
/* A tile's state is derived from the value, not sent as a tag - the component ships the
classifier so a screen does not reimplement thresholds per tile. */
const flowTile = document.getElementById('tileFlow');
Connect.bind(flowTile, {
state: {
tag: 'FIC-101.PV',
quality: false,
transform: function (value) {
return window.Smart.StatusTile.stateFor(value, {
advisory: 88, warning: 92, critical: 96
});
}
}
}, loop);
/* ---------------------------------------------------------------- trend */
trend.min = 0;
trend.max = 120;
/* A time axis, so the source timestamps the stream puts on each record are what
positions it - in sample mode they would be carried and ignored. */
trend.xAxisMode = 'time';
trend.timeSpan = 60000;
trend.pens = [
{ field: 'flow', label: 'FIC-101 flow', unit: 'm³/h' },
/* A pen carries its own range, so the pressure is drawn against 0-12 bar and the
readout still prints 7.26 bar - not a value scaled to fit the flow axis and then
printed in the wrong unit. */
{ field: 'pressure', label: 'PIC-102 pressure', unit: 'bar', min: 0, max: 12 }
];
/* One record per 250 ms with both fields on it, rather than two pushes and two redraws
per instant. The record carries the newest source timestamp of the samples on it.
The two pens come from different sessions, and that is done with one stream and a
session per field. Two streams onto one chart each flush a record carrying only
their own field, so every pen sees a gap on every other record, and a strip chart -
which lifts the pen on a gap rather than drawing through it - draws nothing. */
Connect.stream(trend, {
flow: { tag: 'FIC-101.PV', session: loop },
pressure: { tag: 'PIC-102.PV', session: field }
}, { interval: 250 });
/* ---------------------------------------------------------------- inspector */
const rows = {},
sampleRows = document.getElementById('sampleRows'),
watched = ['FIC-101.PV', 'FIC-101.SP', 'PIC-102.PV', 'TIC-204.PV'];
function formatTime(sample) {
const date = new Date(sample.timestamp);
return date.toLocaleTimeString('en-GB', { hour12: false }) + '.' +
String(date.getMilliseconds()).padStart(3, '0');
}
function show(sample) {
let row = rows[sample.tag];
if (!row) {
row = document.createElement('tr');
row.innerHTML = '<th scope="row"></th><td></td><td></td><td></td><td></td>';
sampleRows.appendChild(row);
rows[sample.tag] = row;
}
const cells = row.children;
cells[0].textContent = sample.tag;
cells[1].textContent = typeof sample.value === 'number' ?
sample.value.toFixed(2) : String(sample.value);
cells[2].textContent = sample.quality;
cells[2].className = 'cx-quality cx-quality-' + sample.quality;
cells[3].textContent = formatTime(sample);
cells[4].textContent = sample.timestampIsLocal ? 'local clock' : 'source';
cells[4].className = sample.timestampIsLocal ? 'cx-local' : '';
}
watched.forEach(function (tag) {
const session = tag.indexOf('FIC-101') === 0 ? loop : field;
session.subscribe(tag, show);
});
/* ---------------------------------------------------------------- controls */
const cutFeed = document.getElementById('cutFeed'),
restoreFeed = document.getElementById('restoreFeed'),
degradeQuality = document.getElementById('degradeQuality'),
feedHint = document.getElementById('feedHint');
cutFeed.addEventListener('click', function () {
clearInterval(feedTimer);
feedTimer = null;
cutFeed.disabled = true;
restoreFeed.disabled = false;
feedHint.textContent = 'FIC-101 silent — watch the quality go stale in 3 s';
feedHint.className = 'cx-hint cx-hint-alert';
});
restoreFeed.addEventListener('click', function () {
if (!feedTimer) {
runFeed();
}
cutFeed.disabled = false;
restoreFeed.disabled = true;
feedHint.textContent = 'Feed live — stale after 3 s of silence';
feedHint.className = 'cx-hint';
});
degradeQuality.addEventListener('click', function () {
reportedQuality = reportedQuality === 'good' ? 'bad' : 'good';
degradeQuality.textContent = reportedQuality === 'good' ?
'Report FIC-101 as bad quality' : 'Report FIC-101 as good quality';
});
/* An operator write goes to the session and never straight to the component. The
faceplate reports the request and moves only when the new value comes back, which is
the same discipline the ISA-18.2 statement describes for acknowledgement. */
loopFlow.interactive = true;
loopFlow.addEventListener('setpointChange', function (event) {
loop.write('FIC-101.SP', event.detail.value);
});
/* ---------------------------------------------------------------- session status */
function paint(session, dot, label, name) {
const state = session.status;
dot.className = 'hmi-conn-dot hmi-conn-' + state;
label.textContent = name + ' · ' + state;
}
setInterval(function () {
paint(loop, document.getElementById('loopDot'),
document.getElementById('loopLabel'), 'loop');
paint(field, document.getElementById('fieldDot'),
document.getElementById('fieldLabel'), 'field');
}, 500);
/* ---------------------------------------------------------------- theme */
const themeToggle = document.getElementById('themeToggle');
themeToggle.addEventListener('click', function () {
const dark = document.body.getAttribute('theme') === 'industrial-dark';
document.body.setAttribute('theme', dark ? 'industrial' : 'industrial-dark');
themeToggle.textContent = dark ? 'Dark' : 'Light';
document.querySelectorAll('smart-faceplate, smart-status-tile, smart-strip-chart')
.forEach(function (element) {
element.theme = dark ? 'industrial' : 'industrial-dark';
});
});
};
styles.css
/* Connectivity demo. Every colour comes from an industrial token, so the screen follows the
theme rather than fighting it - which is also the ISA-101 rule the themes exist to keep. */
body.viewport {
margin: 0;
background: var(--smart-industrial-canvas);
color: var(--smart-industrial-accent-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
}
.hmi-screen {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
min-height: 100vh;
box-sizing: border-box;
}
.hmi-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 14px;
background: var(--smart-industrial-panel-header-background);
border: 1px solid var(--smart-industrial-panel-border);
flex-wrap: wrap;
}
.hmi-identity {
display: flex;
flex-direction: column;
}
.hmi-plant {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.08em;
}
.hmi-area {
font-size: 12px;
}
.hmi-header-status {
display: flex;
align-items: center;
gap: 14px;
font-size: 12px;
}
.hmi-conn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.hmi-conn-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--smart-industrial-ok);
flex: none;
}
/* Connection state is not carried by colour alone - the state word sits next to the dot,
which is the same rule the components follow for alarm state. */
.hmi-conn-connecting,
.hmi-conn-reconnecting {
background: var(--smart-industrial-alarm-warning);
}
.hmi-conn-error,
.hmi-conn-closed {
background: var(--smart-industrial-alarm-critical);
}
.hmi-theme-toggle,
.cx-button {
background: var(--smart-industrial-panel-background);
color: var(--smart-industrial-accent-text);
border: 1px solid var(--smart-industrial-panel-border);
padding: 6px 12px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.cx-button:focus-visible,
.hmi-theme-toggle:focus-visible {
outline: 2px solid var(--smart-industrial-focus);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--smart-industrial-focus-halo);
}
.cx-button[disabled] {
opacity: 0.45;
cursor: default;
}
.cx-button-danger {
border-color: var(--smart-industrial-alarm-critical);
color: var(--smart-industrial-alarm-critical-text);
}
.cx-note,
.cx-panel {
background: var(--smart-industrial-panel-background);
border: 1px solid var(--smart-industrial-panel-border);
padding: 12px 14px;
}
.cx-note p {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.5;
max-width: 92ch;
}
.cx-note p:last-child {
margin-bottom: 0;
}
.cx-note-strong {
border-left: 3px solid var(--smart-industrial-accent-line);
padding-left: 10px;
}
.cx-note code,
.cx-panel-sub code {
/* code in the theme of the screen, not a display's green on black in both */
background: var(--smart-industrial-panel-header-background);
color: var(--smart-text-color);
padding: 1px 5px;
font-size: 12px;
}
.cx-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.cx-hint {
font-size: 12px;
opacity: 0.8;
}
.cx-hint-alert {
color: var(--smart-industrial-alarm-critical-text);
opacity: 1;
font-weight: 600;
}
.cx-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.cx-main {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 12px;
align-items: start;
}
.cx-panel-title {
margin: 0 0 4px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cx-panel-sub {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.5;
opacity: 0.85;
}
.cx-faceplates {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
#trend {
width: 100%;
height: 260px;
}
.cx-wide {
width: 100%;
box-sizing: border-box;
}
.cx-samples {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.cx-samples th,
.cx-samples td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--smart-industrial-panel-border);
}
.cx-samples thead th {
font-size: 11px;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.7;
}
/* The text tokens, not the fill tokens. A colour chosen to be a good fill measures around
3:1 against a panel, which is fine for a chip and below the floor for a word. */
.cx-quality-good {
color: var(--smart-industrial-ok-text);
}
.cx-quality-uncertain {
color: var(--smart-industrial-alarm-warning-text);
}
.cx-quality-bad,
.cx-quality-stale {
color: var(--smart-industrial-alarm-critical-text);
font-weight: 600;
}
.cx-local {
color: var(--smart-industrial-alarm-warning-text);
}
@media (max-width: 720px) {
.cx-main {
grid-template-columns: 1fr;
}
}