diff --git a/experiments/gemba/apps/tv-board/app.js b/experiments/gemba/apps/tv-board/app.js new file mode 100644 index 0000000..bf16566 --- /dev/null +++ b/experiments/gemba/apps/tv-board/app.js @@ -0,0 +1,249 @@ +/* Dallas Line 2 packaging OEE board — LIVE. + * + * Conservative ES5 for the 2019 Tizen web runtime AND ordinary browsers. Replaces the mockup's + * synthetic scenario with the real gateway app-WebSocket: protocol-v1 hello/subscribe, then it + * binds incoming `signals` frames to the dashboard's [data-signal] tiles and drives the pallet + * grid, jam state, motor-current trend, and OEE strip from live values. + * + * Gateway URL: window.GEMBA_TV_CONFIG.gatewayUrl (config.js, used by the packaged TV app) or, + * when served in a browser at /apps/{id}/, derived from location. + */ +(function () { + "use strict"; + + var config = window.GEMBA_TV_CONFIG || {}; + var capabilities = config.capabilities || ["signals", "alarms"]; + var socket = null; + var reconnectTimer = null; + var history = []; + var latest = {}; + var lastData = 0; + function nowMs() { return new Date().getTime(); } + + function appIdFromPath() { + var m = (location.pathname || "").match(/\/apps\/([^\/]+)\//); + return m ? m[1] : "tv-board"; + } + + function gatewayUrl() { + if (config.gatewayUrl) return config.gatewayUrl; + var proto = location.protocol === "https:" ? "wss:" : "ws:"; + return proto + "//" + location.host + "/apps/" + appIdFromPath() + "/ws"; + } + + function byId(id) { return document.getElementById(id); } + function text(id, value) { var el = byId(id); if (el) el.textContent = value; } + function pad(v) { return v < 10 ? "0" + v : String(v); } + function commas(v) { return String(v).replace(/\B(?=(\d{3})+(?!\d))/g, ","); } + function num(v) { return typeof v === "number" ? v : parseFloat(v); } + + function setConnState(state, detail) { + var el = byId("source-state"); + if (el) el.setAttribute("data-state", state); + if (detail) text("source-detail", detail); + } + + // --- pallet grid (24-cell layer view driven by PalletCaseCount) --- + function buildPallet() { + var grid = byId("pallet-grid"); + if (!grid || grid.childNodes.length) return; + for (var i = 0; i < 24; i += 1) { + var cell = document.createElement("span"); + cell.textContent = pad(i + 1); + grid.appendChild(cell); + } + } + function renderPallet(palletCases) { + var loaded = palletCases <= 0 ? 0 : (((palletCases - 1) % 24) + 1); + var grid = byId("pallet-grid"); + if (grid) { + var cells = grid.getElementsByTagName("span"); + for (var i = 0; i < cells.length; i += 1) { + cells[i].className = i < loaded ? "is-loaded" : (i === loaded ? "is-next" : ""); + } + } + var pct = Math.min(100, Math.round((palletCases / 120) * 100)); + text("pallet-cases", palletCases + " / 120"); + text("pallet-percent", pct + "%"); + var bar = byId("pallet-progress-bar"); + if (bar) bar.style.width = pct + "%"; + var layer = Math.min(5, Math.floor((loaded - 1) / (24 / 5)) + 1); + if (layer > 0) { text("pallet-layer", layer + " / 5"); text("pallet-layer2", layer + " / 5"); } + } + + // --- motor-current trend --- + function drawCurrent() { + var width = 600, height = 128, low = 3.5, high = 10.5, path = "", i; + for (i = 0; i < history.length; i += 1) { + var x = history.length === 1 ? 0 : (i / (history.length - 1)) * width; + var y = height - ((history[i] - low) / (high - low)) * height; + path += (i === 0 ? "M" : " L") + x.toFixed(1) + " " + y.toFixed(1); + } + var line = byId("current-line"), area = byId("current-area"); + if (line) line.setAttribute("d", path); + if (area) area.setAttribute("d", path + " L" + width + " " + height + " L0 " + height + " Z"); + } + + // --- jam / running board state --- + function renderState(jammed, state) { + var board = byId("board"); + if (board) board.className = jammed ? "board is-jammed" : "board"; + var pm = byId("packer-machine"); + if (pm) pm.className = jammed ? "machine machine--alert" : "machine machine--good"; + text("status-text", jammed ? "BLOCKED · PACKER JAM" : "RUNNING CLEAN"); + text("status-detail", jammed ? "Robot cell discharge photoeye held" : "Case packer synchronized with palletizer"); + text("packer-state", jammed ? "Jammed" : "Running"); + text("risk-chip", jammed ? "ACTION NOW" : "LOW RISK"); + text("jam-status", jammed ? "BLOCKED" : "CLEAR"); + text("attention-text", jammed + ? "Clear case at packer discharge and inspect guide rail" + : "Carton magazine refill scheduled"); + } + + // signal -> how to render its [data-signal] tile text + var FORMAT = { + CaseRateCpm: function (v) { return num(v).toFixed(1); }, + GoodCaseCount: function (v) { return commas(Math.floor(num(v))); }, + CaseRejectCount: function (v) { return commas(Math.floor(num(v))); }, + PackerMotorCurrentA: function (v) { return num(v).toFixed(1); }, + VisionPassPct: function (v) { return num(v).toFixed(1) + "%"; }, + GlueTempC: function (v) { return String(Math.round(num(v))); }, + CaseWeightKg: function (v) { return num(v).toFixed(2); }, + CartonMagazinePct: function (v) { return Math.round(num(v)) + "% remaining"; }, + JamStatus: function (v) { return (v === true || v === "true") ? "BLOCKED" : "CLEAR"; }, + LabelCode: function (v) { return String(v); }, + PalletizerState: function (v) { return String(v); }, + OEE: function (v) { return num(v).toFixed(1); }, + Availability: function (v) { return num(v).toFixed(1); }, + Performance: function (v) { return num(v).toFixed(1); }, + Quality: function (v) { return num(v).toFixed(1); } + }; + + function applyTile(name, value) { + var fmt = FORMAT[name]; + var out = fmt ? fmt(value) : String(value); + var els = document.querySelectorAll('[data-signal="' + name + '"]'); + for (var i = 0; i < els.length; i += 1) { + els[i].textContent = out; + els[i].className = (els[i].className.replace(/\bvalue-updated\b/, "") + " value-updated").replace(/^\s+/, ""); + } + } + + function ingest(name, value) { + if (value === null || typeof value === "undefined" || !name) return; + lastData = nowMs(); + latest[name] = value; + applyTile(name, value); + + if (name === "PackerMotorCurrentA") { + history.push(num(value)); + if (history.length > 54) history.shift(); + drawCurrent(); + } else if (name === "PalletCaseCount") { + renderPallet(Math.floor(num(value))); + } else if (name === "JamStatus" || name === "PalletizerState") { + var jammed = latest.JamStatus === true || latest.JamStatus === "true" + || latest.PalletizerState === "BLOCKED"; + renderState(jammed, latest.PalletizerState); + } else if (name === "CaseRateCpm") { + var d = num(value) - 28; + text("rate-delta", (d >= 0 ? "+" : "") + d.toFixed(1)); + } + } + + // The snapshot frame (type "signals") carries {name, latest}; the live delta frame + // (type "signal") carries {signal, point:{value}} where `signal` is the name string + // (or a {name} object). Read all forms so DELTAS bind, not just the initial snapshot. + // Snapshot series carry the short display name; delta updates carry `signal` as the CHANNEL PATH, + // which differs per adapter — modbus: bare name ("CartonMagazinePct"); OPC UA: full nodeId + // ("GGCommonsTest.Device1.Live.CaseWeightKg" / "Line1.LineSpeedBpm"); telemetry OEE: topic + // ("gemba/oee/availability"). Normalize all of these to the tile's data-signal name. + var OEE_TOPIC = { availability: "Availability", overall: "OEE", performance: "Performance", quality: "Quality" }; + function normSignal(s) { + if (!s) return s; + if (s.indexOf("gemba/oee/") >= 0) { + var seg = s.split("/").pop(); + return OEE_TOPIC[seg] || seg; + } + if (s.indexOf(".") >= 0) return s.split(".").pop(); // OPC UA nodeId -> last segment + return s; + } + function signalName(x) { + if (!x) return undefined; + if (x.name) return x.name; // snapshot series: short display name + if (x.signal && typeof x.signal === "object" && x.signal.name) return x.signal.name; + if (typeof x.signal === "string") return normSignal(x.signal); // delta: channel path + return x.id; + } + + function handleFrame(frame) { + if (!frame) return; + if (frame.type === "signals") { + var series = frame.series || []; + for (var i = 0; i < series.length; i += 1) { + ingest(signalName(series[i]), series[i].latest); + } + } else if (frame.type === "signal") { + var ups = frame.updates || []; + for (var j = 0; j < ups.length; j += 1) { + var u = ups[j]; + ingest(signalName(u), u.point ? u.point.value : u.value); + } + } + } + + // --- transport --- + function send(obj) { if (socket && socket.readyState === 1) socket.send(JSON.stringify(obj)); } + + function handleMessage(event) { + var msg; + try { msg = JSON.parse(event.data); } catch (e) { return; } + if (msg.type === "welcome") { + setConnState("healthy", "Bridge live · " + (msg.appId || appIdFromPath())); + send({ type: "subscribe", protocolVersion: 1, capabilities: capabilities }); + } else if (msg.type === "subscribed") { + setConnState("healthy", "Live · max " + (msg.maxUpdateHz || 30) + "/s"); + } else if (msg.type === "updates") { + var frames = msg.frames || []; + for (var i = 0; i < frames.length; i += 1) handleFrame(frames[i]); + } else if (msg.type === "error") { + setConnState("warn", msg.code || "error"); + } + } + + function connect() { + setConnState("warn", "Connecting…"); + try { socket = new WebSocket(gatewayUrl()); } + catch (e) { scheduleReconnect(); return; } + socket.onopen = function () { send({ type: "hello", protocolVersion: 1 }); }; + socket.onmessage = handleMessage; + socket.onclose = function () { socket = null; setConnState("warn", "Reconnecting…"); scheduleReconnect(); }; + socket.onerror = function () { setConnState("warn", "Socket error"); }; + } + + function scheduleReconnect() { + if (reconnectTimer) return; + reconnectTimer = window.setTimeout(function () { reconnectTimer = null; connect(); }, 3000); + } + + function tickClock() { + var d = new Date(); + text("clock", pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())); + } + + // Stalled-socket watchdog: a WebSocket can go silent without a clean onclose (e.g. the server + // bounced). If the socket looks open but no data has bound for 12s, force a reconnect so the UI + // reflects reality ("Reconnecting…") instead of a stale "Live". + function watchdog() { + if (socket && socket.readyState === 1 && lastData && (nowMs() - lastData) > 12000) { + setConnState("warn", "Stalled — reconnecting…"); + try { socket.close(); } catch (e) { /* onclose triggers reconnect */ } + } + } + + buildPallet(); + tickClock(); + window.setInterval(tickClock, 1000); + window.setInterval(watchdog, 5000); + connect(); +}()); diff --git a/experiments/gemba/apps/tv-board/config.js b/experiments/gemba/apps/tv-board/config.js new file mode 100644 index 0000000..4ce4127 --- /dev/null +++ b/experiments/gemba/apps/tv-board/config.js @@ -0,0 +1,11 @@ +/* global window */ +/* Browser-served config: leave gatewayUrl unset so app.js derives ws:///apps/{id}/ws from the + * page location (works when the console serves this at /apps/tv-board/). The packaged Tizen build + * ships its own config.js with an explicit LAN gatewayUrl (see experiments/gemba/tv/tizen-gemba). */ +(function () { + "use strict"; + window.GEMBA_TV_CONFIG = { + protocolVersion: 1, + capabilities: ["signals", "alarms"] + }; +}()); diff --git a/experiments/gemba/apps/tv-board/index.html b/experiments/gemba/apps/tv-board/index.html new file mode 100644 index 0000000..6d014cc --- /dev/null +++ b/experiments/gemba/apps/tv-board/index.html @@ -0,0 +1,109 @@ + + + + + + Dallas Line 2 — Packaging OEE board + + + +
+
+
+ + BOTTLES R USDALLAS · PACKAGING HALL +
+
LINE02
+
+ Connecting… + + SHIFT A · LIVE +
+
+ +
+
OEE--%
+
AVAILABILITY--%
+
PERFORMANCE--%
+
QUALITY--%
+
+ +
+
PACKAGING STATUSRUNNING CLEANCase packer synchronized with palletizer
+
ACTIVE ORDERSPRK-LIME-355 · 24 PACKWO 71042 · Customer DC-04
+
ACTUAL RATE--CASES / MINTarget 28.0 · --
+
+ +
+
+
+
01 · PROCESS

Pack flow

All interlocks made
+
+
A
CASE ERECTORRunning62% blanks
+ +
B
ROBOTIC PACKERRunning6.2 A load
+ +
C
CASE SEALERRunning176 °C glue
+ +
D
LABEL + VISIONPassing99.4% verify
+ +
E
PALLETIZERBuildingLayer 4 / 5
+
+
+ +
+
+
02 · SHIFT

Throughput

live
+
+
--good cases
+
+
Good casesReject cases
+
Reject cases--Microstops7Blocked time--
+
+
+ +
+
03 · PACKER LOAD

Jam early warning

LOW RISK
+
+
--ANormal 5.4–7.1 A
+ + + 9.0 A JAM RISK + + + +
Jam switch CLEARPalletizer BUILDING
+
+
+
+ +
+
V
VISION PASS--Target ≥ 99.0%
+
°
GLUE TEMPERATURE-- °CBand 172–180 °C
+
kg
CASE WEIGHT-- kg±0.08 kg
+
LABEL VERIFY--Grade A · readable
+
+
+ + +
+ +
+
NEXT ATTENTIONCarton magazine refill scheduled--
+
OPC UA Kepware · palletizer1 MODBUS Host sim · casepacker1
+
+
+ + + + diff --git a/experiments/gemba/apps/tv-board/styles.css b/experiments/gemba/apps/tv-board/styles.css new file mode 100644 index 0000000..0f70df7 --- /dev/null +++ b/experiments/gemba/apps/tv-board/styles.css @@ -0,0 +1,167 @@ +:root { + --ink: #23233d; + --ink-2: #343653; + --paper: #f6f3eb; + --panel: #fffdf8; + --line: #d9d8d1; + --muted: #6f7080; + --iris: #5d69a8; + --copper: #d67a4e; + --carton: #c89552; + --safety: #eab545; + --good: #3f8f69; + --danger: #bd4d56; + --display: "Arial Narrow", "Roboto Condensed", "Aptos Narrow", sans-serif; + --body: Manrope, "Segoe UI", Arial, sans-serif; + --mono: "JetBrains Mono", Consolas, monospace; +} + +* { box-sizing: border-box; } +html, body { width: 100%; min-height: 100%; } +body { margin: 0; background: #d9d8d1; color: var(--ink); font-family: var(--body); -webkit-font-smoothing: antialiased; } +.board { min-height: 100vh; background: var(--paper); border-top: 8px solid var(--safety); overflow: hidden; } +.header { min-height: 100px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 14px 40px; background: var(--ink); color: white; } +.brand { display: flex; align-items: center; gap: 15px; } +.brand__mark { display: flex; align-items: end; gap: 4px; height: 38px; } +.brand__mark i { display: block; width: 8px; background: var(--safety); }.brand__mark i:nth-child(1) { height: 20px; }.brand__mark i:nth-child(2) { height: 29px; }.brand__mark i:nth-child(3) { height: 38px; background: var(--copper); } +.brand b { display: block; font: 700 24px/1 var(--display); letter-spacing: .14em; }.brand small { display: block; color: #b8b9c8; font: 600 11px/1 var(--mono); margin-top: 7px; letter-spacing: .1em; } +.line-id { display: flex; align-items: center; gap: 11px; border-left: 1px solid #4d4e69; border-right: 1px solid #4d4e69; padding: 0 35px; } +.line-id span { color: #b8b9c8; font: 700 12px/1 var(--mono); letter-spacing: .16em; writing-mode: vertical-rl; transform: rotate(180deg); }.line-id strong { font: 800 58px/.9 var(--display); } +.header__meta { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; gap: 8px 24px; text-align: right; } +.header__meta time { font: 600 25px/1 var(--mono); }.header__meta small { grid-column: 1 / -1; color: #b8b9c8; font: 600 10px/1 var(--mono); letter-spacing: .08em; } +.source-state { color: #74c398; font: 700 11px/1 var(--mono); letter-spacing: .07em; }.source-state i { display: inline-block; width: 8px; height: 8px; background: #74c398; border-radius: 50%; margin-right: 7px; box-shadow: 0 0 0 5px rgba(116,195,152,.12); } + +.status-band { display: grid; grid-template-columns: 1.12fr 1fr .68fr; min-height: 160px; border-bottom: 2px solid var(--ink); background: var(--panel); } +.status-band > div { padding: 25px 36px; display: flex; flex-direction: column; justify-content: center; } +.status-band > div + div { border-left: 1px solid var(--line); } +.status-band span { color: var(--muted); font: 700 10px/1 var(--mono); letter-spacing: .15em; }.status-band small { color: var(--muted); font-size: 12px; margin-top: 8px; } +.status-band__label strong { font: 800 45px/1 var(--display); letter-spacing: -.01em; margin-top: 8px; }.status-band__label strong::before { content: ""; display: inline-block; width: 14px; height: 30px; background: var(--good); margin-right: 14px; } +.status-band__sku b { font: 700 25px/1.2 var(--display); margin-top: 10px; } +.status-band__rate { background: var(--safety); color: var(--ink); }.status-band__rate span, .status-band__rate small { color: #584415; }.status-band__rate strong { font: 800 62px/.88 var(--display); margin-top: 8px; }.status-band__rate b { font: 700 12px/1 var(--mono); letter-spacing: .08em; margin-top: 7px; }.status-band__rate small i { font-style: normal; font-weight: 700; } + +.content { display: grid; grid-template-columns: minmax(0, 1fr) 440px; min-height: 710px; } +.operations { min-width: 0; padding: 24px 26px 18px 38px; border-right: 2px solid var(--ink); } +.section-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; } +.section-heading small { display: block; color: var(--muted); font: 700 9px/1 var(--mono); letter-spacing: .14em; }.section-heading h1, .section-heading h2 { margin: 5px 0 0; font: 750 25px/1 var(--display); }.section-heading em { align-self: center; color: var(--good); font: 700 10px/1 var(--mono); font-style: normal; letter-spacing: .06em; } +.flow-track { display: grid; grid-template-columns: 1fr 26px 1.18fr 26px 1fr 26px 1.15fr 26px 1fr; align-items: center; gap: 0; margin-top: 15px; } +.machine { position: relative; min-height: 95px; padding: 14px 13px 12px 46px; background: var(--panel); border: 1px solid var(--line); } +.machine__index { position: absolute; left: 13px; top: 13px; display: grid; place-items: center; width: 24px; height: 24px; background: var(--ink); color: white; font: 700 11px/1 var(--mono); } +.machine small, .machine em { display: block; color: var(--muted); font: 700 8px/1.25 var(--mono); font-style: normal; letter-spacing: .05em; }.machine b { display: block; font: 700 18px/1.4 var(--display); }.machine i { position: absolute; left: 0; right: 0; bottom: 0; height: 5px; background: var(--good); } +.flow-arrow { color: var(--muted); text-align: center; font: 300 31px/1 var(--display); } +.operation-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 18px; } +.panel { min-height: 270px; padding: 18px 20px; background: var(--panel); border: 1px solid var(--line); } +.ahead { color: var(--iris) !important; }.throughput-main { display: flex; align-items: baseline; gap: 11px; margin-top: 20px; }.throughput-main strong { font: 800 49px/.9 var(--display); }.throughput-main span { color: var(--muted); font-size: 11px; } +.plan-line { position: relative; height: 10px; margin-top: 22px; background: #e6e4de; overflow: visible; }.plan-line i { display: block; width: 97%; height: 100%; background: var(--iris); }.plan-line mark { position: absolute; left: 96.7%; top: -5px; width: 2px; height: 20px; background: var(--ink); } +.plan-labels { display: flex; justify-content: space-between; color: var(--muted); font: 600 8px/1 var(--mono); margin-top: 8px; } +.throughput-stats { display: grid; grid-template-columns: repeat(3,1fr); margin-top: 22px; padding-top: 13px; border-top: 1px solid var(--line); }.throughput-stats span + span { border-left: 1px solid var(--line); padding-left: 15px; }.throughput-stats small { display: block; color: var(--muted); font-size: 9px; }.throughput-stats b { display: block; font: 700 16px/1.6 var(--mono); } +.risk-chip { color: var(--good) !important; border: 1px solid #91bfa7; padding: 6px 8px; }.current-reading { display: flex; align-items: baseline; margin-top: 14px; }.current-reading strong { font: 800 43px/.9 var(--display); }.current-reading > span { color: var(--copper); font: 700 15px/1 var(--mono); margin-left: 6px; }.current-reading small { color: var(--muted); font-size: 9px; margin-left: 13px; } +.current-chart { width: 100%; height: 98px; margin-top: -7px; overflow: visible; }.current-chart .warn-line { stroke: var(--danger); stroke-width: 1; stroke-dasharray: 5 5; }.current-chart text { fill: var(--danger); font: 700 7px/1 var(--mono); }.current-chart #current-line { fill: none; stroke: var(--copper); stroke-width: 3; vector-effect: non-scaling-stroke; }.current-chart #current-area { fill: rgba(214,122,78,.14); } +.jam-state { display: flex; justify-content: space-between; color: var(--muted); font-size: 9px; }.jam-state i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--good); margin-right: 6px; }.jam-state b { color: var(--ink); font-family: var(--mono); } +.quality-row { display: grid; grid-template-columns: repeat(4,1fr); gap: 12px; margin-top: 15px; }.quality-row article { display: flex; gap: 11px; align-items: center; min-height: 76px; background: var(--ink); color: white; padding: 12px; }.quality-row small, .quality-row em { display: block; color: #b8b9c8; font: 700 8px/1.25 var(--mono); font-style: normal; }.quality-row b { display: block; font: 700 17px/1.45 var(--display); }.quality-row b i { font-style: normal; } +.quality-icon { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border: 2px solid var(--safety); color: var(--safety); font: 800 13px/1 var(--display); }.quality-icon--glue { border-color: var(--copper); color: var(--copper); }.quality-icon--weight { border-color: #aab6e0; color: #aab6e0; font-size: 9px; }.quality-icon--label { border-color: #75c59a; color: #75c59a; } + +.pallet-panel { padding: 24px 28px 20px; background: #ebe8df; } +.pallet-panel__top { display: flex; justify-content: space-between; align-items: center; }.pallet-panel__top small { color: var(--muted); font: 700 9px/1 var(--mono); letter-spacing: .14em; }.pallet-panel__top h2 { margin: 5px 0 0; font: 800 32px/1 var(--display); }.pallet-panel__top em { background: var(--ink); color: white; padding: 7px 10px; font: 700 9px/1 var(--mono); font-style: normal; } +.pallet-progress { display: grid; grid-template-columns: auto 1fr; column-gap: 9px; align-items: baseline; margin-top: 18px; }.pallet-progress strong { font: 800 58px/.9 var(--display); }.pallet-progress span { color: var(--muted); font-size: 11px; }.pallet-progress > i { grid-column: 1 / -1; height: 8px; background: #d6d1c7; margin-top: 10px; }.pallet-progress i b { display: block; width: 76%; height: 100%; background: var(--carton); } +.pallet-copy { display: grid; grid-template-columns: 1fr 1fr; margin-top: 17px; border-top: 1px solid #cdc8be; border-bottom: 1px solid #cdc8be; padding: 12px 0; }.pallet-copy span + span { border-left: 1px solid #cdc8be; padding-left: 18px; }.pallet-copy small { display: block; color: var(--muted); font-size: 9px; }.pallet-copy b { display: block; font: 700 17px/1.5 var(--mono); } +.pallet-grid { display: grid; grid-template-columns: repeat(6, 1fr); grid-auto-rows: 46px; gap: 6px; padding: 22px 10px 14px; border-bottom: 8px solid #6d5232; perspective: 400px; }.pallet-grid span { display: grid; place-items: center; border: 2px solid #b8b3a8; color: #8b887e; font: 700 9px/1 var(--mono); background: transparent; }.pallet-grid span.is-loaded { border-color: #9c6932; background: var(--carton); color: #3d2b19; box-shadow: inset 0 -5px 0 rgba(84,56,26,.14); }.pallet-grid span.is-next { border-color: var(--iris); color: var(--iris); background: #e8e9f5; animation: next-pick 1.5s steps(2,end) infinite; } +@keyframes next-pick { 50% { outline: 3px solid rgba(93,105,168,.28); } } +.pallet-key { display: flex; justify-content: center; gap: 18px; color: var(--muted); font-size: 9px; margin-top: 13px; }.pallet-key i { display: inline-block; width: 10px; height: 10px; border: 1px solid #aaa59b; vertical-align: -1px; margin-right: 5px; }.pallet-key i.loaded { background: var(--carton); border-color: #9c6932; }.pallet-key i.next { background: #e8e9f5; border-color: var(--iris); } +.pallet-foot { display: grid; grid-template-columns: 1fr 1fr; margin-top: 20px; }.pallet-foot span + span { border-left: 1px solid #cdc8be; padding-left: 18px; }.pallet-foot small { display: block; color: var(--muted); font-size: 9px; }.pallet-foot b { display: block; font: 700 14px/1.6 var(--mono); } + +.footer { display: grid; grid-template-columns: 1.35fr 1fr auto; align-items: center; min-height: 92px; border-top: 2px solid var(--ink); background: var(--panel); padding: 12px 38px; gap: 22px; }.material-watch { display: flex; align-items: center; gap: 13px; min-width: 0; }.material-watch__stripe { width: 16px; height: 52px; flex: 0 0 auto; background: repeating-linear-gradient(-45deg, var(--safety) 0 7px, var(--ink) 7px 14px); }.material-watch small { display: block; color: #987018; font: 700 9px/1 var(--mono); letter-spacing: .12em; }.material-watch b { display: block; margin-top: 5px; font: 650 13px/1.3 var(--body); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.material-watch em { margin-left: auto; color: var(--muted); font: 700 10px/1 var(--mono); font-style: normal; white-space: nowrap; } +.source-note { color: var(--muted); font: 600 8px/1.45 var(--mono); text-align: right; }.source-note b { color: var(--ink); }.source-note span { display: inline-block; width: 1px; height: 13px; background: var(--line); margin: 0 8px; vertical-align: -3px; } +button { appearance: none; border: 2px solid var(--ink); background: var(--paper); color: var(--ink); padding: 9px 12px; font: 700 10px/1 var(--body); cursor: pointer; }.footer button span { display: inline-grid; place-items: center; width: 22px; height: 22px; margin-right: 7px; background: var(--ink); color: white; font-family: var(--mono); }button:focus { outline: 4px solid var(--iris); outline-offset: 3px; } + +.board.is-jammed .status-band__label strong { color: var(--danger); }.board.is-jammed .status-band__label strong::before { background: var(--danger); }.board.is-jammed .status-band__rate { background: #e8d3d2; }.board.is-jammed .machine--alert { border: 3px solid var(--danger); background: #f7e7e5; }.board.is-jammed .machine--alert i { background: var(--danger); }.board.is-jammed .risk-chip { color: var(--danger) !important; border-color: var(--danger); }.board.is-jammed .jam-state i { background: var(--danger); } + +@media (max-width: 1450px) { .content { grid-template-columns: minmax(0,1fr) 360px; }.operations { padding-left: 24px; }.flow-track { grid-template-columns: 1fr 18px 1.1fr 18px 1fr 18px 1.05fr 18px 1fr; }.machine { padding-left: 39px; }.machine__index { left: 9px; }.status-band > div { padding-left: 25px; padding-right: 25px; } } +@media (max-width: 1050px) { body { overflow: auto; }.content { grid-template-columns: 1fr; }.operations { border-right: 0; }.pallet-panel { border-top: 2px solid var(--ink); }.flow-track { grid-template-columns: 1fr; gap: 7px; }.flow-arrow { transform: rotate(90deg); }.operation-grid, .quality-row { grid-template-columns: 1fr 1fr; }.footer { grid-template-columns: 1fr auto; }.source-note { display: none; } } +@media (prefers-reduced-motion: reduce) { .pallet-grid span.is-next { animation: none; } } + +/* --- OEE band (live packaging OEE, added for the OEE dashboard) --- */ +.oee-band { display: grid; grid-template-columns: .9fr 1fr 1fr 1fr; min-height: 118px; background: var(--ink); color: #fff; border-bottom: 2px solid var(--safety); } +.oee-band > div { padding: 16px 34px; display: flex; flex-direction: column; justify-content: center; } +.oee-band > div + div { border-left: 1px solid #3a3c58; } +.oee-band span { color: #b8b9c8; font: 700 10px/1 var(--mono); letter-spacing: .16em; } +.oee-band__hero { background: var(--safety); color: var(--ink); flex-direction: row !important; align-items: baseline; gap: 12px; } +.oee-band__hero span { color: #584415; align-self: center; } +.oee-band__hero strong { font: 800 66px/.85 var(--display); } +.oee-band__hero b { font: 700 20px/1 var(--display); color: #584415; } +.oee-band__part b { font: 800 40px/1 var(--display); margin-top: 9px; } +.oee-band__part b i { font-style: normal; } + +/* --- connection state pill --- */ +.source-state[data-state="warn"] { color: var(--safety); } +.source-state[data-state="warn"] i { background: var(--safety); box-shadow: 0 0 0 5px rgba(234,181,69,.15); } +.source-state[data-state="healthy"] { color: #74c398; } +.source-state[data-state="healthy"] i { background: #74c398; box-shadow: 0 0 0 5px rgba(116,195,152,.12); } + +/* --- value change flash --- */ +.value-updated { animation: flash 0.6s ease-out; } +@keyframes flash { 0% { color: var(--copper); } 100% { color: inherit; } } + +/* --- TV-at-distance legibility (55" suspended above a line): labels raised to a legible floor. + The process-flow cards are excluded from that bump — they are tiny (5 across), so their text is + sized to fit and the box made taller so the layer line stays inside. --- */ +.brand small, .header__meta small, .source-state, #source-detail, +.status-band span, .status-band small, .status-band__rate b, .status-band__rate small, +.section-heading small, .section-heading em, .throughput-main span, .plan-labels, +.throughput-stats small, .current-reading > span, .current-reading small, .jam-state, +.quality-row small, .quality-row em, .pallet-panel__top small, .pallet-progress span, +.pallet-copy small, .pallet-key, .pallet-foot small, .material-watch small, .material-watch em, +.source-note, .oee-band span, .oee-band__part span { font-size: 19px !important; letter-spacing: .04em; } +.throughput-stats b, .pallet-copy b, .pallet-foot b, .material-watch b { font-size: 27px !important; } +.section-heading h1, .section-heading h2 { font-size: 30px !important; } +.jam-state b { font-size: 22px !important; } +.status-band__sku b { font-size: 30px !important; } +/* process-flow cards: fit the small tiles, taller box keeps the layer line inside */ +.machine { min-height: 142px !important; } +.machine small, .machine em { font-size: 16px !important; line-height: 1.35 !important; } +.machine b { font-size: 22px !important; line-height: 1.35 !important; } +/* the .machine i bottom-bar rule is a descendant selector that also catches the nested + layer value in step E; keep that one inline text, not an absolute bar. */ +.machine div i { position: static !important; left: auto !important; right: auto !important; bottom: auto !important; height: auto !important; background: transparent !important; display: inline !important; font-style: normal !important; } + +/* --- TV fit + section-header + warn-label refinements (verified on the 55" panel) --- + 1) 02/03 headers float ABOVE their cards (the .op-col wrapper) like section 01; + 2) 02/03 are separated by a real column gutter and set apart from 01 by a top gap; the two + content cards are forced to equal height and their main measure shares one font size; + 3) the motor-current warn label is right-aligned (see index.html ); + 4) the whole board is trimmed to fit 1080p with a small overscan cushion — fonts untouched, + only paddings/margins/box heights reclaimed. + NOTE: this 2019 Tizen web runtime (Chromium ~63) ignores the `gap` shorthand on grid, so the + 02/03 gutter MUST use the grid-column-gap longhand — `gap` alone renders zero gutter. --- */ +.op-col { display: flex; flex-direction: column; min-width: 0; height: 100%; } +.op-col .section-heading { margin-bottom: 10px; } +.op-col .panel { margin-top: 0 !important; flex: 1 1 auto; } +.oee-band { min-height: 90px !important; } +.oee-band > div { padding-top: 8px !important; padding-bottom: 9px !important; } +.oee-band__hero strong { font-size: 58px !important; } +.status-band { min-height: 138px !important; } +.status-band > div { padding-top: 11px !important; padding-bottom: 11px !important; } +.status-band__rate strong { font-size: 52px !important; } +.status-band__label strong { font-size: 40px !important; } +.content { min-height: 0 !important; } +.operations { padding-top: 16px !important; padding-bottom: 8px !important; } +.machine { min-height: 104px !important; } +.operation-grid { margin-top: 22px !important; grid-column-gap: 28px !important; column-gap: 28px !important; } +.throughput-main strong, .current-reading strong { font-size: 48px !important; } +.panel { min-height: 0 !important; padding-top: 10px !important; padding-bottom: 10px !important; } +/* 03 (jam) card: flow the content down the full card height — the big reading aligns with 02's, + the dotted 9.0 A warn line sits at 02's good/reject bar, the chart is taller, and the jam-switch/ + palletizer row is pinned to the bottom. */ +.jam-panel { display: flex; flex-direction: column; } +.current-reading { margin-top: 20px; } +.current-chart { flex: 0 0 auto; height: 118px !important; margin-top: 6px !important; } +.current-chart text { font-size: 12px !important; } +.jam-state { margin-top: auto; } +.quality-row { margin-top: 9px !important; } +.quality-row article { min-height: 64px !important; } +.footer { min-height: 70px !important; } +.pallet-panel { padding-top: 16px !important; padding-bottom: 12px !important; } +.pallet-progress { margin-top: 10px !important; } +.pallet-copy { margin-top: 10px !important; padding: 9px 0 !important; } +.pallet-grid { grid-auto-rows: 40px !important; padding: 12px 10px 10px !important; } +.pallet-key { margin-top: 8px !important; } +.pallet-foot { margin-top: 10px !important; } diff --git a/experiments/gemba/tv/google-tv-gemba/.gitignore b/experiments/gemba/tv/google-tv-gemba/.gitignore new file mode 100644 index 0000000..007cd29 --- /dev/null +++ b/experiments/gemba/tv/google-tv-gemba/.gitignore @@ -0,0 +1,8 @@ +# Android / Gradle build outputs and machine-specific config +local.properties +/.gradle/ +/build/ +/app/build/ +*.iml +.idea/ +.cxx/ diff --git a/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/BarMeterView.java b/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/BarMeterView.java new file mode 100644 index 0000000..ee13b38 --- /dev/null +++ b/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/BarMeterView.java @@ -0,0 +1,45 @@ +package dev.edgecommons.gembatv; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.view.View; + +/** + * A flat, square-cornered stacked bar over a track — used for the reject split (overfill / underfill + * / cap). Fractions are of the full width and should sum to <= 1. + */ +public final class BarMeterView extends View { + private static final int TRACK = Color.rgb(233, 230, 221); + + private final Paint p = new Paint(Paint.ANTI_ALIAS_FLAG); + private float[] fractions = new float[0]; + private int[] colors = new int[0]; + + public BarMeterView(Context c) { + super(c); + p.setStyle(Paint.Style.FILL); + } + + public void set(float[] fr, int[] cols) { + fractions = fr; + colors = cols; + invalidate(); + } + + @Override + protected void onDraw(Canvas cv) { + int w = getWidth(), h = getHeight(); + p.setColor(TRACK); + cv.drawRect(0, 0, w, h, p); + float x = 0; + for (int i = 0; i < fractions.length && i < colors.length; i++) { + float seg = Math.max(0, fractions[i]) * w; + if (seg <= 0) continue; + p.setColor(colors[i]); + cv.drawRect(x, 0, Math.min(w, x + seg), h, p); + x += seg; + } + } +} diff --git a/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/BulletBarView.java b/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/BulletBarView.java new file mode 100644 index 0000000..fa679a3 --- /dev/null +++ b/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/BulletBarView.java @@ -0,0 +1,67 @@ +package dev.edgecommons.gembatv; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.view.View; + +/** + * A flat bullet bar in the proper bullet-chart grammar: a track, a pale tolerance band, a value fill + * from the range minimum to the current value (GOOD inside the band, COPPER above it, the configured + * below-colour under it), and a bold ink TARGET marker (the perpendicular tick) at the setpoint. The + * gap between the fill end and the target tick is the deviation, read at a glance without a needle. + */ +public final class BulletBarView extends View { + private static final int TRACK = Color.rgb(233, 230, 221); + private static final int BAND = Color.rgb(241, 223, 174); + private static final int GOOD = Color.rgb(63, 143, 105); + private static final int COPPER = Color.rgb(214, 122, 78); + private static final int IRIS = Color.rgb(93, 105, 168); + private static final int INK = Color.rgb(35, 35, 61); + + private final Paint p = new Paint(Paint.ANTI_ALIAS_FLAG); + private float min = 0, max = 100, bandLo = 0, bandHi = 100, value = Float.NaN, target = Float.NaN; + private int belowColor = IRIS; // colour of the fill when the value sits below the target band + + public BulletBarView(Context c) { + super(c); + p.setStyle(Paint.Style.FILL); + } + + public void setRange(float mn, float mx) { min = mn; max = mx; } + public void setBand(float lo, float hi) { bandLo = lo; bandHi = hi; } + public void setValue(float v) { value = v; invalidate(); } + + /** The setpoint marker (perpendicular tick). Leave unset to draw no target. */ + public void setTarget(float t) { target = t; invalidate(); } + + /** Fill colour used when the value is below the target band (default IRIS; amber for rate). */ + public void setBelowColor(int c) { belowColor = c; } + + private float frac(float v) { + if (max <= min) return 0; + return Math.max(0, Math.min(1, (v - min) / (max - min))); + } + + @Override + protected void onDraw(Canvas cv) { + int w = getWidth(), h = getHeight(); + p.setColor(TRACK); + cv.drawRect(0, 0, w, h, p); + p.setColor(BAND); + cv.drawRect(frac(bandLo) * w, 0, frac(bandHi) * w, h, p); + if (!Float.isNaN(value)) { + p.setColor(value > bandHi ? COPPER : (value < bandLo ? belowColor : GOOD)); + cv.drawRect(0, 0, frac(value) * w, h, p); + } + if (!Float.isNaN(target)) { + // target marker stands a little proud of the bar so it reads as a setpoint, not a fill edge + float tx = frac(target) * w; + p.setColor(INK); + cv.drawRect(tx - dp(1.5f), -dp(3), tx + dp(1.5f), h + dp(3), p); + } + } + + private float dp(float v) { return v * getResources().getDisplayMetrics().density; } +} diff --git a/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/MainActivity.java b/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/MainActivity.java index 4a66046..431988f 100644 --- a/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/MainActivity.java +++ b/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/MainActivity.java @@ -10,23 +10,24 @@ import android.os.Handler; import android.os.Looper; import android.os.SystemClock; -import android.text.InputType; import android.view.Gravity; import android.view.View; import android.view.Window; import android.view.WindowManager; -import android.widget.Button; -import android.widget.EditText; import android.widget.LinearLayout; -import android.widget.ScrollView; import android.widget.TextView; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; -import java.util.Random; +import java.util.Map; import java.util.concurrent.TimeUnit; import okhttp3.OkHttpClient; @@ -35,34 +36,75 @@ import okhttp3.WebSocket; import okhttp3.WebSocketListener; +/** + * Native Android TV (Sony / Google TV) OEE board for the Dallas FILLING line (gw-fill-01). + * + * A flat "broadsheet" HMI: full-width bands separated by hairline/ink rules (no rounded cards), a + * strict measure/typography system, and colour used only where it means something. Header · OEE + * band · status band · content (fill line + fill quality | line speed + reject split) · line-health + * footer. Driven live from the edge-console app-WebSocket, scoped to the filling device. + */ public final class MainActivity extends Activity { private static final String PREFS = "gemba-tv"; private static final String PREF_GATEWAY_URL = "gateway-url"; - private static final String DEFAULT_GATEWAY_URL = - "ws://192.168.1.224:18445/apps/tv-board/ws"; + private static final String DEFAULT_GATEWAY_URL = "ws://192.168.1.224:8080/apps/tv-board/ws"; private static final String APP_ORIGIN = "https://google-tv.edgecommons.local"; private static final int PROTOCOL_VERSION = 1; - private static final String[] CAPABILITIES = { - "fleet", "events", "signals", "attributes", "alarms" - }; + private static final String[] CAPABILITIES = {"signals", "alarms"}; + private static final String LINE_DEVICE = "gw-fill-01"; + private static final float TARGET_BPM = 132f; // 60000 / idealCycleMs(454.545) + + // Semantic palette — one meaning each + private static final int PAPER = Color.rgb(246, 243, 235); + private static final int PANEL = Color.rgb(255, 253, 248); + private static final int INK = Color.rgb(35, 35, 61); + private static final int LINE = Color.rgb(217, 216, 209); + private static final int MUTED = Color.rgb(111, 112, 128); + private static final int SAFETY = Color.rgb(234, 181, 69); + private static final int SAFETY_INK = Color.rgb(88, 68, 21); + private static final int GOOD = Color.rgb(63, 143, 105); + private static final int DANGER = Color.rgb(189, 77, 86); + private static final int COPPER = Color.rgb(214, 122, 78); + private static final int IRIS = Color.rgb(93, 105, 168); + private static final int CARTON = Color.rgb(200, 149, 82); + private static final int ON_INK = Color.WHITE; + private static final int ON_INK_MUTED = Color.rgb(184, 185, 200); + private static final int INK_HAIR = Color.rgb(77, 78, 105); + private static final int TINT_SAFETY = Color.rgb(253, 247, 233); + private static final int TINT_DANGER = Color.rgb(247, 231, 229); + + private Typeface cond; private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private final Random random = new Random(); private final OkHttpClient client = new OkHttpClient.Builder() .pingInterval(15, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS) .build(); private SharedPreferences preferences; - private EditText gatewayInput; + private String gatewayUrl = DEFAULT_GATEWAY_URL; + private TextView statusView; - private TextView envelopeCountView; - private TextView rateView; - private TextView frameCountView; - private TextView reconnectCountView; - private TextView lastMessageView; - private TextView lastErrorView; - private TextView latestUpdateView; + private TextView clockView; + private View statusDot; + // One signal may drive several displays (e.g. FillPressureKpa feeds both the FILLER stage tile + // and the fill-quality spec bullet), so each key maps to a list of views. + private final Map> valueViews = new LinkedHashMap<>(); + private final SimpleDateFormat clockFmt = new SimpleDateFormat("HH:mm:ss", Locale.US); + + // graphical + composite elements + private TankGaugeView tank; + private BulletBarView pressureBullet, volumeBullet, tempBullet, co2Bullet; + private BarMeterView splitBar; + private TextView rateValue, rateDelta, healthEStop, healthConv, healthInfeed; + private LinearLayout infeedTile, fillerTile, capperTile; + private View infeedBar, fillerBar, capperBar; + private TextView statusWord, statusSub, goodSub, oeeVal, rateTargetSub; + private View oeeAccent, healthStripe; + private BulletBarView rateBullet; + + private long good = -1, rejects = -1, overfill = -1, underfill = -1, cap = -1; + private boolean running = true, eStop = true, convRun = true, infeedStarved = false; private WebSocket webSocket; private Runnable reconnectRunnable; @@ -70,55 +112,43 @@ public final class MainActivity extends Activity { private boolean lifecycleStopped = true; private int generation; private int reconnectAttempt; - private long reconnects; - private long envelopes; - private long frames; - private long rateWindowStartedAt = SystemClock.elapsedRealtime(); - private long rateWindowEnvelopes; + private long lastDataAt; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); requestWindowFeature(Window.FEATURE_NO_TITLE); - getWindow().setFlags( - WindowManager.LayoutParams.FLAG_FULLSCREEN, - WindowManager.LayoutParams.FLAG_FULLSCREEN - ); + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + cond = Typeface.create("sans-serif-condensed", Typeface.BOLD); preferences = getSharedPreferences(PREFS, MODE_PRIVATE); - buildUi(); + gatewayUrl = preferences.getString(PREF_GATEWAY_URL, DEFAULT_GATEWAY_URL); applyBridgeUrlFromIntent(getIntent()); + buildUi(); + startClock(); } - @Override - protected void onNewIntent(Intent intent) { + @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); - if (applyBridgeUrlFromIntent(intent)) { - manualDisconnect = false; - connect(); - } + if (applyBridgeUrlFromIntent(intent)) { manualDisconnect = false; connect(); } } - @Override - protected void onStart() { + @Override protected void onStart() { super.onStart(); lifecycleStopped = false; - if (!manualDisconnect) { - connect(); - } + if (!manualDisconnect) connect(); } - @Override - protected void onStop() { + @Override protected void onStop() { lifecycleStopped = true; cancelReconnect(); closeCurrentSocket(1001, "TV app stopped"); super.onStop(); } - @Override - protected void onDestroy() { + @Override protected void onDestroy() { cancelReconnect(); closeCurrentSocket(1000, "TV app destroyed"); super.onDestroy(); @@ -126,434 +156,905 @@ protected void onDestroy() { private boolean applyBridgeUrlFromIntent(Intent intent) { String supplied = intent == null ? null : intent.getStringExtra("bridgeUrl"); - if (supplied == null || !validGatewayUrl(supplied.trim())) { - return false; - } - String value = supplied.trim(); - gatewayInput.setText(value); - preferences.edit().putString(PREF_GATEWAY_URL, value).apply(); + if (supplied == null || !validGatewayUrl(supplied.trim())) return false; + gatewayUrl = supplied.trim(); + preferences.edit().putString(PREF_GATEWAY_URL, gatewayUrl).apply(); return true; } - private void buildUi() { - int background = Color.rgb(7, 25, 35); - int panel = Color.rgb(13, 38, 49); - int border = Color.rgb(36, 69, 83); - int primary = Color.rgb(54, 194, 180); - int muted = Color.rgb(158, 181, 194); + // ----------------------------------------------------------------- UI + private void buildUi() { LinearLayout root = new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL); - root.setPadding(dp(44), dp(32), dp(44), dp(24)); - root.setBackgroundColor(background); - - LinearLayout header = new LinearLayout(this); - header.setGravity(Gravity.CENTER_VERTICAL); - TextView title = text("Dallas Gemba Board", 36, Color.WHITE, true); - header.addView(title, weighted(1)); - statusView = text("Starting", 20, Color.WHITE, true); - statusView.setGravity(Gravity.CENTER); - statusView.setPadding(dp(24), dp(12), dp(24), dp(12)); - setStatus("Starting", false, false); - header.addView(statusView, wrap()); - root.addView(header, matchWrap()); - - View accent = new View(this); - accent.setBackgroundColor(primary); - LinearLayout.LayoutParams accentParams = match(dp(5)); - accentParams.setMargins(0, dp(16), 0, dp(20)); - root.addView(accent, accentParams); - - LinearLayout metricsRow = new LinearLayout(this); - metricsRow.setOrientation(LinearLayout.HORIZONTAL); - envelopeCountView = metricCard(metricsRow, "UPDATE ENVELOPES", panel, border); - rateView = metricCard(metricsRow, "CURRENT RATE", panel, border); - frameCountView = metricCard(metricsRow, "FRAMES OBSERVED", panel, border); - reconnectCountView = metricCard(metricsRow, "RECONNECTS", panel, border); - root.addView(metricsRow, matchWrap()); - - LinearLayout details = new LinearLayout(this); - details.setOrientation(LinearLayout.HORIZONTAL); - LinearLayout.LayoutParams detailsParams = match(0); - detailsParams.weight = 1; - detailsParams.setMargins(0, dp(20), 0, 0); - root.addView(details, detailsParams); - - LinearLayout connectionPanel = panel(panel, border); - LinearLayout.LayoutParams connectionParams = weighted(42); - connectionParams.setMargins(0, 0, dp(12), 0); - details.addView(connectionPanel, connectionParams); - connectionPanel.addView(text("Connection", 24, Color.WHITE, true), matchWrap()); - - TextView gatewayLabel = text("Gateway WebSocket URL", 15, muted, false); - LinearLayout.LayoutParams labelParams = matchWrap(); - labelParams.setMargins(0, dp(16), 0, dp(6)); - connectionPanel.addView(gatewayLabel, labelParams); - - gatewayInput = new EditText(this); - gatewayInput.setSingleLine(true); - gatewayInput.setTextColor(background); - gatewayInput.setTextSize(17); - gatewayInput.setSelectAllOnFocus(true); - gatewayInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); - gatewayInput.setText(preferences.getString(PREF_GATEWAY_URL, DEFAULT_GATEWAY_URL)); - gatewayInput.setBackground(rounded(Color.rgb(236, 245, 248), primary, 2, 8)); - gatewayInput.setPadding(dp(12), dp(9), dp(12), dp(9)); - connectionPanel.addView(gatewayInput, matchWrap()); - - LinearLayout buttons = new LinearLayout(this); - buttons.setOrientation(LinearLayout.HORIZONTAL); - LinearLayout.LayoutParams buttonsParams = matchWrap(); - buttonsParams.setMargins(0, dp(12), 0, dp(14)); - connectionPanel.addView(buttons, buttonsParams); - - Button connectButton = button("Save and connect", Color.rgb(22, 123, 114)); - connectButton.setOnClickListener(view -> { - manualDisconnect = false; - connect(); - }); - LinearLayout.LayoutParams buttonParams = weighted(1); - buttonParams.setMargins(0, 0, dp(8), 0); - buttons.addView(connectButton, buttonParams); - - Button disconnectButton = button("Disconnect", Color.rgb(77, 101, 112)); - disconnectButton.setOnClickListener(view -> { - manualDisconnect = true; - cancelReconnect(); - closeCurrentSocket(1000, "User disconnected"); - setStatus("Disconnected", false, false); - }); - buttons.addView(disconnectButton, weighted(1)); - - connectionPanel.addView(keyValue("Client", "Native Android / OkHttp", muted), matchWrap()); - connectionPanel.addView(keyValue("Origin header", APP_ORIGIN, muted), matchWrap()); - lastMessageView = keyValue("Last message", "None", muted); - connectionPanel.addView(lastMessageView, matchWrap()); - lastErrorView = keyValue("Last error", "None", muted); - connectionPanel.addView(lastErrorView, matchWrap()); - - LinearLayout payloadPanel = panel(panel, border); - LinearLayout.LayoutParams payloadParams = weighted(58); - payloadParams.setMargins(dp(12), 0, 0, 0); - details.addView(payloadPanel, payloadParams); - payloadPanel.addView(text("Latest update", 24, Color.WHITE, true), matchWrap()); - - latestUpdateView = text("Waiting for the gateway...", 14, Color.rgb(213, 237, 242), false); - latestUpdateView.setTypeface(Typeface.MONOSPACE); - latestUpdateView.setTextIsSelectable(true); - ScrollView payloadScroll = new ScrollView(this); - payloadScroll.addView(latestUpdateView, matchWrap()); - LinearLayout.LayoutParams scrollParams = match(0); - scrollParams.weight = 1; - scrollParams.setMargins(0, dp(12), 0, 0); - payloadPanel.addView(payloadScroll, scrollParams); - - TextView footer = text( - "D-pad selects controls. Native ping every 15 seconds. Gateway delivery ceiling: 30 Hz.", - 14, - muted, - false - ); - footer.setGravity(Gravity.CENTER); - LinearLayout.LayoutParams footerParams = matchWrap(); - footerParams.setMargins(0, dp(14), 0, 0); - root.addView(footer, footerParams); + root.setPadding(dp(20), dp(20), dp(20), dp(20)); + root.setBackgroundColor(PAPER); + + root.addView(rule(4, SAFETY)); + root.addView(header(), band(44)); + root.addView(oeeBand(), band(58)); + root.addView(rule(2, SAFETY)); + root.addView(statusBand(), band(52)); + root.addView(rule(1, LINE)); + root.addView(gap(10)); + root.addView(content(), band(274)); // fixed height so nested MATCH_PARENT columns resolve + root.addView(gap(8)); + root.addView(rule(2, INK)); + root.addView(footer(), band(42)); setContentView(root); - envelopeCountView.setText("0"); - rateView.setText("0.0 Hz"); - frameCountView.setText("0"); - reconnectCountView.setText("0"); - } - - private TextView metricCard(LinearLayout row, String label, int background, int border) { - LinearLayout card = panel(background, border); - card.setPadding(dp(18), dp(14), dp(18), dp(14)); - card.addView(text(label, 14, Color.rgb(158, 181, 194), false), matchWrap()); - TextView value = text("0", 31, Color.WHITE, true); - LinearLayout.LayoutParams valueParams = matchWrap(); - valueParams.setMargins(0, dp(8), 0, 0); - card.addView(value, valueParams); - LinearLayout.LayoutParams cardParams = weighted(1); - cardParams.setMargins(dp(5), 0, dp(5), 0); - row.addView(card, cardParams); - return value; - } - - private LinearLayout panel(int background, int border) { - LinearLayout panel = new LinearLayout(this); - panel.setOrientation(LinearLayout.VERTICAL); - panel.setPadding(dp(22), dp(20), dp(22), dp(20)); - panel.setBackground(rounded(background, border, 1, 12)); - return panel; - } - - private TextView keyValue(String key, String value, int muted) { - TextView view = text(key + ": " + value, 15, Color.WHITE, false); - view.setPadding(0, dp(5), 0, dp(5)); - view.setContentDescription(key); - view.setTag(key); - return view; - } - - private Button button(String label, int color) { - Button button = new Button(this); - button.setText(label); - button.setTextColor(Color.WHITE); - button.setTextSize(16); - button.setAllCaps(false); - button.setFocusable(true); - button.setBackground(rounded(color, Color.rgb(255, 207, 74), 0, 8)); - return button; - } - - private TextView text(String value, int sizeSp, int color, boolean bold) { - TextView view = new TextView(this); - view.setText(value); - view.setTextSize(sizeSp); - view.setTextColor(color); - if (bold) { - view.setTypeface(Typeface.DEFAULT, Typeface.BOLD); - } - return view; + setConn("CONNECTING", SAFETY); } - private GradientDrawable rounded(int fill, int stroke, int strokeWidthDp, int radiusDp) { - GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(fill); - drawable.setCornerRadius(dp(radiusDp)); - if (strokeWidthDp > 0) { - drawable.setStroke(dp(strokeWidthDp), stroke); - } - return drawable; + // Band 1 — header: logo glyph + name | LINE 01 | clock + state + private LinearLayout header() { + LinearLayout h = row(Gravity.CENTER_VERTICAL); + h.setBackgroundColor(INK); + h.setPadding(dp(20), 0, dp(20), 0); + + // Left group in a weight-1 cell, and the right group in another, so the identity block + // between them sits at the true screen centre regardless of the side groups' widths. + LinearLayout left = row(Gravity.CENTER_VERTICAL); + // brand mark: a filled bottle-cap disc over a body bar — reads as packaging, not signal bars + LinearLayout glyph = new LinearLayout(this); + glyph.setOrientation(LinearLayout.HORIZONTAL); + glyph.setGravity(Gravity.CENTER_VERTICAL); + View cap = new View(this); + cap.setBackground(dot(SAFETY)); + glyph.addView(cap, new LinearLayout.LayoutParams(dp(12), dp(12))); + View body = new View(this); + body.setBackgroundColor(COPPER); + LinearLayout.LayoutParams bl = new LinearLayout.LayoutParams(dp(6), dp(22)); + bl.leftMargin = dp(3); + glyph.addView(body, bl); + left.addView(glyph, wc()); + + LinearLayout name = col(); + LinearLayout.LayoutParams nlp = wc(); + nlp.leftMargin = dp(10); + TextView brand = new TextView(this); + brand.setText("BOTTLES R US"); + brand.setTypeface(cond); + brand.setTextSize(17); + brand.setTextColor(ON_INK); + brand.setLetterSpacing(0.08f); + name.addView(brand, wc()); + name.addView(micro("DALLAS · FILLING HALL", ON_INK_MUTED, 9), wc()); + left.addView(name, nlp); + h.addView(left, weight(1)); + + // centred masthead identity: LINE / FILLING stacked, big 01 to its right + LinearLayout lineCell = row(Gravity.CENTER_VERTICAL); + LinearLayout lblStack = col(); + lblStack.addView(micro("LINE", ON_INK_MUTED, 11), wc()); + LinearLayout.LayoutParams f2 = wc(); + f2.topMargin = dp(1); + lblStack.addView(micro("FILLING", ON_INK_MUTED, 11), f2); + lineCell.addView(lblStack, wc()); + TextView ln = new TextView(this); + ln.setText("01"); + ln.setTypeface(cond); + ln.setTextSize(34); + ln.setTextColor(ON_INK); + LinearLayout.LayoutParams llp = wc(); + llp.leftMargin = dp(12); + lineCell.addView(ln, llp); + h.addView(lineCell, wc()); + + LinearLayout right = col(); + right.setGravity(Gravity.END); + clockView = new TextView(this); + clockView.setText("--:--:--"); + clockView.setTypeface(Typeface.MONOSPACE, Typeface.BOLD); + clockView.setTextSize(16); + clockView.setTextColor(ON_INK); + right.addView(clockView, wc()); + LinearLayout st = row(Gravity.CENTER_VERTICAL); + statusDot = new View(this); + statusDot.setBackground(dot(SAFETY)); + LinearLayout.LayoutParams dl = new LinearLayout.LayoutParams(dp(7), dp(7)); + dl.rightMargin = dp(6); + st.addView(statusDot, dl); + statusView = micro("CONNECTING", SAFETY, 10); + st.addView(statusView, wc()); + right.addView(st, wc()); + h.addView(right, weight(1)); + return h; } - private void connect() { - String url = gatewayInput.getText().toString().trim(); - if (!validGatewayUrl(url)) { - setLastError("Enter a ws:// or wss:// gateway URL."); - gatewayInput.requestFocus(); - return; + // Band 2 — OEE band: hero + A/P/Q, hairline-divided + private LinearLayout oeeBand() { + LinearLayout b = row(Gravity.FILL_VERTICAL); + b.setBackgroundColor(INK); + + // OEE is the composite hero (A/P/Q are its factors), so it gets a distinct larger baseline + // treatment. A slim status accent left of the number carries threshold state (see bindGraphic). + LinearLayout hero = row(Gravity.CENTER_VERTICAL); + hero.setPadding(dp(20), 0, dp(20), 0); + oeeAccent = new View(this); + oeeAccent.setBackgroundColor(GOOD); + LinearLayout.LayoutParams al = new LinearLayout.LayoutParams(dp(5), dp(38)); + al.rightMargin = dp(12); + hero.addView(oeeAccent, al); + LinearLayout oeeLbl = col(); + oeeLbl.addView(micro("OEE", ON_INK_MUTED, 11), wc()); + LinearLayout.LayoutParams brl = wc(); + brl.topMargin = dp(1); + oeeLbl.addView(micro("SHIFT", ON_INK_MUTED, 8), brl); + LinearLayout.LayoutParams lblL = wc(); + lblL.rightMargin = dp(10); + hero.addView(oeeLbl, lblL); + LinearLayout oeeRow = row(Gravity.BOTTOM); + oeeVal = val("--", ON_INK, 44); + bindValue("OEE", oeeVal); + oeeRow.addView(oeeVal, wc()); + hero.addView(oeeRow, wc()); + b.addView(hero, weightFill(1.15f)); + + b.addView(vrule(INK_HAIR)); + b.addView(inkPart("AVAILABILITY", "Availability"), weightFill(1f)); + b.addView(vrule(INK_HAIR)); + b.addView(inkPart("PERFORMANCE", "Performance"), weightFill(1f)); + b.addView(vrule(INK_HAIR)); + b.addView(inkPart("QUALITY", "Quality"), weightFill(1f)); + return b; + } + + private LinearLayout inkPart(String label, String sig) { + LinearLayout p = col(); + p.setGravity(Gravity.CENTER_VERTICAL); + p.setPadding(dp(20), 0, dp(20), 0); + p.addView(micro(label, ON_INK_MUTED, 11), wc()); + TextView v = val("--", ON_INK, 30); + bindValue(sig, v); + LinearLayout.LayoutParams lp = wc(); + lp.topMargin = dp(2); + p.addView(v, lp); + return p; + } + + // Band 3 — status: filling status | good bottles | actual rate (amber) + private LinearLayout statusBand() { + LinearLayout b = row(Gravity.FILL_VERTICAL); + b.setBackgroundColor(PANEL); + + LinearLayout c1 = col(); + c1.setGravity(Gravity.CENTER_VERTICAL); + c1.setPadding(dp(20), 0, dp(20), 0); + c1.addView(micro("FILLING STATUS", MUTED, 11), wc()); + LinearLayout wrow = row(Gravity.CENTER_VERTICAL); + View blk = new View(this); + blk.setBackgroundColor(GOOD); + LinearLayout.LayoutParams bl = new LinearLayout.LayoutParams(dp(9), dp(22)); + bl.rightMargin = dp(10); + wrow.addView(blk, bl); + statusDotBlock = blk; + statusWord = val("RUNNING", INK, 30); + wrow.addView(statusWord, wc()); + LinearLayout.LayoutParams w2 = wc(); + w2.topMargin = dp(2); + c1.addView(wrow, w2); + statusSub = sub("Filler synchronized"); + c1.addView(statusSub, wc()); + b.addView(c1, weightFill(1.3f)); + + b.addView(vrule(LINE)); + LinearLayout c2 = col(); + c2.setGravity(Gravity.CENTER_VERTICAL); + c2.setPadding(dp(20), 0, dp(20), 0); + c2.addView(micro("GOOD BOTTLES", MUTED, 11), wc()); + TextView gv = val("--", INK, 30); + bindValue("GoodBottleCount", gv); + LinearLayout.LayoutParams gl = wc(); + gl.topMargin = dp(2); + c2.addView(gv, gl); + goodSub = sub("this shift"); + c2.addView(goodSub, wc()); + b.addView(c2, weightFill(1f)); + + b.addView(vrule(LINE)); + LinearLayout c3 = col(); + c3.setGravity(Gravity.CENTER_VERTICAL); + c3.setPadding(dp(20), 0, dp(20), 0); + c3.addView(micro("ACTUAL RATE", MUTED, 11), wc()); + LinearLayout rrow = row(Gravity.BOTTOM); + TextView rv = val("--", INK, 30); + bindValue("LineSpeedBpm", rv); + rrow.addView(rv, wc()); + rrow.addView(unit(" BPM"), wcBottom()); + c3.addView(rrow, topM(2)); + c3.addView(sub("target " + Math.round(TARGET_BPM)), wc()); + b.addView(c3, weightFill(0.95f)); + return b; + } + private View statusDotBlock; + + // Band 4 — content. Columns are wrap-height with fixed-height children, so nothing depends on a + // MATCH_PARENT height that a nested weighted layout would fail to resolve. + // A single column: the fill-line strip on top, then one horizontal row of three panels. This + // uses only the horizontal-row-of-weight-fill-cells pattern (as the status band and flow strip + // do), which measures reliably — a nested vertical rail collapsed its children. + private LinearLayout content() { + LinearLayout c = col(); + c.addView(sectionHead(null, "PROCESS FLOW", null), mw()); + c.addView(flowStrip(), rowW(dp(6), 88)); + + LinearLayout r = row(Gravity.FILL_VERTICAL); + r.addView(qualityPanel(), weightFillM(2.15f, dp(14))); + r.addView(ratePanel(), weightFillM(1.15f, dp(14))); + r.addView(rejectPanel(), weightFill(0.95f)); + c.addView(r, rowW(dp(12), 0, 1f)); + return c; + } + + private LinearLayout.LayoutParams weightW(float w) { return new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, w); } + private LinearLayout.LayoutParams weightWM(float w, int rightDp) { LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, w); lp.rightMargin = rightDp; return lp; } + + private View sectionHead(String num, String title, String rightTag) { + LinearLayout h = row(Gravity.CENTER_VERTICAL); + // a short accent keyline stands in for the old number chip (line identity lives in the header) + View key = new View(this); + key.setBackgroundColor(SAFETY); + LinearLayout.LayoutParams kl = new LinearLayout.LayoutParams(dp(4), dp(16)); + kl.rightMargin = dp(10); + h.addView(key, kl); + TextView t = new TextView(this); + t.setText(title); + t.setTypeface(cond); + t.setTextSize(17); + t.setTextColor(INK); + h.addView(t, wc()); + if (rightTag != null) { + h.addView(spacerW(), weight(1)); + h.addView(micro(rightTag, GOOD, 11), wc()); } + return h; + } - preferences.edit().putString(PREF_GATEWAY_URL, url).apply(); - cancelReconnect(); - closeCurrentSocket(1001, "Reconnecting"); - manualDisconnect = false; - int connectionGeneration = ++generation; - setStatus("Connecting", false, true); - setLastError("None"); + // 01 flow: three instrumented stages + private View flowStrip() { + LinearLayout s = row(Gravity.FILL_VERTICAL); + infeedTile = flowTile("A", "INFEED", "InfeedMetric", "ConveyorSpeedPct", "%"); + infeedBar = (View) infeedTile.getTag(); + s.addView(infeedTile, weightFillM(1f, dp(0))); + s.addView(chevron()); + fillerTile = flowTile("B", "FILLER", "FillerMetric", "FillPressureKpa", "kPa"); + fillerBar = (View) fillerTile.getTag(); + s.addView(fillerTile, weightFillM(1f, dp(0))); + s.addView(chevron()); + capperTile = flowTile("C", "CAPPER", "CapperMetric", "CapRejectCount", "rej"); + capperBar = (View) capperTile.getTag(); + s.addView(capperTile, weightFillM(1f, dp(0))); + return s; + } - Request request = new Request.Builder() - .url(url) - .header("Origin", APP_ORIGIN) - .build(); - webSocket = client.newWebSocket(request, new GembaListener(connectionGeneration)); + private LinearLayout flowTile(String idx, String name, String subKey, String valSig, String unitStr) { + LinearLayout t = col(); + t.setBackground(panelBg(LINE, 1)); + LinearLayout inner = col(); + inner.setPadding(dp(12), dp(9), dp(12), dp(9)); + LinearLayout top = row(Gravity.CENTER_VERTICAL); + TextView sq = new TextView(this); + sq.setText(idx); + sq.setTypeface(cond); + sq.setTextSize(11); + sq.setTextColor(ON_INK); + sq.setGravity(Gravity.CENTER); + sq.setBackgroundColor(INK); + sq.setPadding(dp(6), 0, dp(6), 0); + top.addView(sq, wc()); + LinearLayout.LayoutParams nlp = wc(); + nlp.leftMargin = dp(8); + top.addView(micro(name, MUTED, 11), nlp); + inner.addView(top, mw()); + LinearLayout vrow = row(Gravity.BOTTOM); + TextView v = val("--", INK, 24); + bindValue(valSig, v); + vrow.addView(v, wc()); + if (!unitStr.isEmpty()) vrow.addView(unit(" " + unitStr), wcBottom()); + inner.addView(vrow, topM(4)); + TextView sb = sub("—"); + bindValue(subKey, sb); + inner.addView(sb, wc()); + LinearLayout.LayoutParams il = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f); + t.addView(inner, il); + View barV = new View(this); + barV.setBackgroundColor(LINE); // neutral until a state signal drives it (capper stays neutral) + t.addView(barV, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(4))); + t.setTag(barV); + return t; } - private final class GembaListener extends WebSocketListener { - private final int connectionGeneration; + private View chevron() { + TextView c = new TextView(this); + c.setText("▸"); + c.setTypeface(cond); + c.setTextSize(30); + c.setTextColor(SAFETY); + LinearLayout.LayoutParams lp = wc(); + lp.leftMargin = dp(6); + lp.rightMargin = dp(6); + lp.gravity = Gravity.CENTER_VERTICAL; + c.setLayoutParams(lp); + return c; + } - private GembaListener(int connectionGeneration) { - this.connectionGeneration = connectionGeneration; - } + // 02 fill quality: tank + value, and a 2x2 measure grid + private View qualityPanel() { + LinearLayout p = row(Gravity.FILL_VERTICAL); + p.setBackground(panelBg(LINE, 1)); + p.setPadding(dp(14), dp(12), dp(14), dp(12)); + + tank = new TankGaugeView(this); + tank.setLowThreshold(35); + p.addView(tank, new LinearLayout.LayoutParams(dp(52), LinearLayout.LayoutParams.MATCH_PARENT)); + + LinearLayout bowlV = col(); + bowlV.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams bvl = wc(); + bvl.leftMargin = dp(14); + bvl.rightMargin = dp(16); + bowlV.addView(micro("BOWL LEVEL", MUTED, 11), wc()); + LinearLayout brow = row(Gravity.BOTTOM); + TextView bval = val("--", INK, 24); + bindValue("BowlLevelPct", bval); + brow.addView(bval, wc()); + brow.addView(unit(" %"), wcBottom()); + bowlV.addView(brow, topM(2)); + bowlV.addView(sub("low < 35%"), wc()); + p.addView(bowlV, bvl); + + LinearLayout.LayoutParams sepL = new LinearLayout.LayoutParams(dp(1), LinearLayout.LayoutParams.MATCH_PARENT); + sepL.rightMargin = dp(16); + p.addView(vrule(LINE), sepL); + + LinearLayout grid = col(); + LinearLayout r1 = row(Gravity.FILL_VERTICAL); + pressureBullet = new BulletBarView(this); + pressureBullet.setRange(90, 150); + pressureBullet.setBand(108, 126); + pressureBullet.setTarget(117); + r1.addView(measureBullet("FILL PRESSURE", "FillPressureKpa", "kPa", "108–126", pressureBullet), weightFillM(1f, dp(16))); + volumeBullet = new BulletBarView(this); + volumeBullet.setRange(485, 515); + volumeBullet.setBand(498, 502); + volumeBullet.setTarget(500); + r1.addView(measureBullet("FILL VOLUME", "FillVolumeMl", "mL", "498–502", volumeBullet), weightFill(1f)); + grid.addView(r1, rowW(0, 0, 1f)); + LinearLayout r2 = row(Gravity.FILL_VERTICAL); + tempBullet = new BulletBarView(this); + tempBullet.setRange(0, 10); + tempBullet.setBand(2, 6); + tempBullet.setTarget(4); + r2.addView(measureBullet("PRODUCT TEMP", "ProductTempC", "°C", "2–6", tempBullet), weightFillM(1f, dp(16))); + co2Bullet = new BulletBarView(this); + co2Bullet.setRange(2.0f, 3.2f); + co2Bullet.setBand(2.5f, 2.8f); + co2Bullet.setTarget(2.65f); + r2.addView(measureBullet("CO₂ VOLUMES", "CO2Volumes", "", "2.5–2.8", co2Bullet), weightFill(1f)); + grid.addView(r2, rowW(dp(6), 0, 1f)); + p.addView(grid, weightFill(1f)); + return p; + } - private boolean active() { - return connectionGeneration == generation && !lifecycleStopped; - } + private View measureBullet(String label, String sig, String unitStr, String rangeSub, BulletBarView bullet) { + LinearLayout v = col(); + v.setGravity(Gravity.CENTER_VERTICAL); + v.addView(micro(label, MUTED, 11), wc()); + LinearLayout row = row(Gravity.BOTTOM); + TextView val = val("--", INK, 24); + bindValue(sig, val); + row.addView(val, wc()); + if (!unitStr.isEmpty()) row.addView(unit(" " + unitStr), wcBottom()); + v.addView(row, topM(2)); + LinearLayout.LayoutParams bl = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, dp(8)); + bl.topMargin = dp(6); + v.addView(bullet, bl); + v.addView(sub("spec " + rangeSub), topM(3)); + return v; + } - @Override - public void onOpen(WebSocket socket, Response response) { - if (!active()) { - socket.cancel(); - return; - } - post(() -> setStatus("Handshaking", false, true)); - socket.send(helloFrame()); - } + private View measurePlain(String label, String sig, String unitStr) { + LinearLayout v = col(); + v.setGravity(Gravity.CENTER_VERTICAL); + v.addView(micro(label, MUTED, 11), wc()); + LinearLayout row = row(Gravity.BOTTOM); + TextView val = val("--", INK, 24); + bindValue(sig, val); + row.addView(val, wc()); + if (!unitStr.isEmpty()) row.addView(unit(" " + unitStr), wcBottom()); + v.addView(row, topM(2)); + return v; + } - @Override - public void onMessage(WebSocket socket, String text) { - if (!active()) { - return; - } - try { - JSONObject message = new JSONObject(text); - String type = message.optString("type", "unknown"); - post(() -> setLastMessage(type)); - if ("welcome".equals(type)) { - reconnectAttempt = 0; - post(() -> setStatus("Live", true, false)); - socket.send(subscribeFrame()); - } else if ("updates".equals(type)) { - JSONArray updateFrames = message.optJSONArray("frames"); - int frameDelta = updateFrames == null ? 0 : updateFrames.length(); - post(() -> recordUpdate(text, frameDelta)); - } else if ("error".equals(type)) { - String error = message.optString("code", "gateway-error") + ": " - + message.optString("message", text); - post(() -> setLastError(error)); + // 03 rate vs target: the analytical view — current BPM positioned within its operating band, + // above the ideal-rate target. The status band carries the bare number; this is where it earns + // context (how far from target, which side). + private View ratePanel() { + LinearLayout p = col(); + p.setBackground(panelBg(LINE, 1)); + p.setPadding(dp(14), dp(12), dp(14), dp(12)); + p.addView(micro("RATE vs TARGET", MUTED, 11), mw()); + + LinearLayout head = row(Gravity.BOTTOM); + rateValue = val("--", INK, 36); + head.addView(rateValue, wc()); + head.addView(unit(" BPM"), wcBottom()); + rateDelta = new TextView(this); + rateDelta.setText("—"); + rateDelta.setTypeface(cond); + rateDelta.setTextSize(20); + rateDelta.setTextColor(MUTED); + LinearLayout.LayoutParams ddl = wcBottom(); + ddl.leftMargin = dp(14); + head.addView(rateDelta, ddl); + p.addView(head, topM(10)); + + // target-band gauge: fill is green in the target band, amber below, copper above; ink tick at value + rateBullet = new BulletBarView(this); + rateBullet.setRange(80, 150); + rateBullet.setBand(126, 138); + rateBullet.setTarget(TARGET_BPM); + rateBullet.setBelowColor(SAFETY); + LinearLayout.LayoutParams gl = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, dp(12)); + gl.topMargin = dp(12); + p.addView(rateBullet, gl); + + // scale legend: end anchors plus the target label positioned under its tick (132 on 80–150 ≈ 0.74) + LinearLayout sc = row(Gravity.CENTER_VERTICAL); + sc.addView(sub("80"), wc()); + sc.addView(spacerW(), weight(2.85f)); + rateTargetSub = sub("target " + Math.round(TARGET_BPM)); + sc.addView(rateTargetSub, wc()); + sc.addView(spacerW(), weight(1f)); + sc.addView(sub("150"), wc()); + p.addView(sc, topM(5)); + return p; + } + + // 04 reject split + private View rejectPanel() { + LinearLayout p = col(); + p.setBackground(panelBg(LINE, 1)); + p.setPadding(dp(14), dp(12), dp(14), dp(12)); + p.addView(micro("REJECTS THIS SHIFT", MUTED, 11), mw()); + + LinearLayout head = row(Gravity.BOTTOM); + TextView rt = val("--", INK, 28); + bindValue("RejectCount", rt); + head.addView(rt, wc()); + head.addView(unit(" bottles"), wcBottom()); + p.addView(head, topM(6)); + + splitBar = new BarMeterView(this); + LinearLayout.LayoutParams sl = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, dp(10)); + sl.topMargin = dp(12); + p.addView(splitBar, sl); + + // category breakdown, each swatch keyed to its bar segment + LinearLayout cats = row(Gravity.CENTER_VERTICAL); + rejOver = catCell(cats, "OVER", COPPER); + rejUnder = catCell(cats, "UNDER", IRIS); + rejCap = catCell(cats, "CAP", CARTON); + p.addView(cats, topM(10)); + return p; + } + + private TextView rejOver, rejUnder, rejCap; + + private TextView catCell(LinearLayout parent, String label, int swatch) { + LinearLayout c = col(); + LinearLayout lr = row(Gravity.CENTER_VERTICAL); + View sw = new View(this); + sw.setBackgroundColor(swatch); + LinearLayout.LayoutParams swl = new LinearLayout.LayoutParams(dp(8), dp(8)); + swl.rightMargin = dp(5); + lr.addView(sw, swl); + lr.addView(micro(label, MUTED, 9), wc()); + c.addView(lr, wc()); + TextView v = val("--", INK, 17); + c.addView(v, topM(2)); + parent.addView(c, weight(1)); + return v; + } + + // Band 5 — footer: line health + private LinearLayout footer() { + LinearLayout f = row(Gravity.CENTER_VERTICAL); + f.setBackgroundColor(PANEL); + f.setPadding(dp(16), 0, dp(16), 0); + healthStripe = new View(this); + healthStripe.setBackgroundColor(GOOD); + LinearLayout.LayoutParams st = new LinearLayout.LayoutParams(dp(10), dp(26)); + st.rightMargin = dp(14); + f.addView(healthStripe, st); + f.addView(micro("LINE HEALTH", MUTED, 11), wc()); + LinearLayout clauses = row(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams cl = wc(); + cl.leftMargin = dp(14); + healthEStop = sub("E-stop healthy"); + clauses.addView(healthEStop, wc()); + clauses.addView(dotSep()); + healthConv = sub("Conveyor running"); + clauses.addView(healthConv, wc()); + clauses.addView(dotSep()); + healthInfeed = sub("Infeed OK"); + clauses.addView(healthInfeed, wc()); + f.addView(clauses, cl); + f.addView(spacerW(), weight(1)); + f.addView(micro("GW-FILL-01 · EDGE-CONSOLE WS", MUTED, 10), wc()); + return f; + } + + // ---------------------------------------------------------------- signals + + private void handleUpdates(JSONArray frames) { + if (frames == null) return; + for (int i = 0; i < frames.length(); i++) { + JSONObject frame = frames.optJSONObject(i); + if (frame == null) continue; + String type = frame.optString("type"); + if ("signals".equals(type)) { + JSONArray series = frame.optJSONArray("series"); + for (int j = 0; series != null && j < series.length(); j++) { + JSONObject item = series.optJSONObject(j); + if (item != null && isFillingLine(item)) ingest(normSignal(item.optString("name", null)), item.opt("latest")); + } + } else if ("signal".equals(type)) { + JSONArray updates = frame.optJSONArray("updates"); + for (int j = 0; updates != null && j < updates.length(); j++) { + JSONObject item = updates.optJSONObject(j); + if (item == null || !isFillingLine(item)) continue; + JSONObject point = item.optJSONObject("point"); + Object value = point != null ? point.opt("value") : null; + ingest(normSignal(item.optString("signal", null)), value); } - } catch (JSONException error) { - post(() -> setLastError("Invalid JSON: " + error.getMessage())); } } + } - @Override - public void onClosing(WebSocket socket, int code, String reason) { - socket.close(code, reason); - } + private boolean isFillingLine(JSONObject item) { + JSONObject key = item.optJSONObject("key"); + return key != null && LINE_DEVICE.equals(key.optString("device")); + } - @Override - public void onClosed(WebSocket socket, int code, String reason) { - if (active()) { - post(() -> { - setLastError("Closed " + code + (reason.isEmpty() ? "" : ": " + reason)); - scheduleReconnect(); - }); + private static String normSignal(String s) { + if (s == null) return null; + if (s.contains("gemba/oee/")) { + String seg = s.substring(s.lastIndexOf('/') + 1); + switch (seg) { + case "availability": return "Availability"; + case "overall": return "OEE"; + case "performance": return "Performance"; + case "quality": return "Quality"; + default: return seg; } } + int dot = s.lastIndexOf('.'); + return dot >= 0 ? s.substring(dot + 1) : s; + } - @Override - public void onFailure(WebSocket socket, Throwable error, Response response) { - if (active()) { - String detail = response == null - ? error.getClass().getSimpleName() + ": " + error.getMessage() - : "HTTP " + response.code() + ": " + error.getMessage(); - post(() -> { - setLastError(detail); - scheduleReconnect(); - }); - } - } + /** Register a display for a signal; a signal may drive more than one view. */ + private void bindValue(String key, TextView view) { + valueViews.computeIfAbsent(key, k -> new ArrayList<>()).add(view); } - private String helloFrame() { - try { - return new JSONObject() - .put("type", "hello") - .put("protocolVersion", PROTOCOL_VERSION) - .toString(); - } catch (JSONException impossible) { - throw new IllegalStateException(impossible); - } + /** First view registered for a signal, or null — for callers that own a single display. */ + private TextView firstView(String key) { + List views = valueViews.get(key); + return views == null || views.isEmpty() ? null : views.get(0); } - private String subscribeFrame() { - try { - JSONArray requested = new JSONArray(); - for (String capability : CAPABILITIES) { - requested.put(capability); + private void ingest(String name, Object value) { + if (name == null || value == null) return; + lastDataAt = SystemClock.elapsedRealtime(); + final List views = valueViews.get(name); + final double d = toDouble(value); + final String raw = String.valueOf(value); + post(() -> { + if (views != null) { + for (TextView v : views) v.setText(format(name, value, v)); } - return new JSONObject() - .put("type", "subscribe") - .put("protocolVersion", PROTOCOL_VERSION) - .put("capabilities", requested) - .toString(); - } catch (JSONException impossible) { - throw new IllegalStateException(impossible); - } + bindGraphic(name, raw, d); + setConn("LIVE", GOOD); + }); } - private void recordUpdate(String payload, int frameDelta) { - envelopes += 1; - frames += frameDelta; - rateWindowEnvelopes += 1; - long now = SystemClock.elapsedRealtime(); - long elapsed = now - rateWindowStartedAt; - if (elapsed >= 1000) { - double rate = rateWindowEnvelopes * 1000.0 / elapsed; - rateView.setText(String.format(Locale.US, "%.1f Hz", rate)); - rateWindowStartedAt = now; - rateWindowEnvelopes = 0; + private void bindGraphic(String name, String raw, double d) { + switch (name) { + case "OEE": if (oeeAccent != null) oeeAccent.setBackgroundColor(d >= 85 ? GOOD : (d >= 70 ? SAFETY : DANGER)); break; + case "BowlLevelPct": if (tank != null) tank.setLevel((float) d); break; + case "FillPressureKpa": if (pressureBullet != null) pressureBullet.setValue((float) d); break; + case "FillVolumeMl": if (volumeBullet != null) volumeBullet.setValue((float) d); break; + case "ProductTempC": if (tempBullet != null) tempBullet.setValue((float) d); break; + case "CO2Volumes": if (co2Bullet != null) co2Bullet.setValue((float) d); break; + case "LineSpeedBpm": updateRateDelta((float) d); break; + case "GoodBottleCount": good = (long) d; updateTally(); break; + case "RejectCount": rejects = (long) d; break; + case "OverfillRejectCount": overfill = (long) d; updateTally(); break; + case "UnderfillRejectCount": underfill = (long) d; updateTally(); break; + case "CapRejectCount": cap = (long) d; updateTally(); break; + case "FillerState": updateFiller(raw); break; + case "ConveyorSpeedPct": updateInfeed(); break; + case "ConveyorRunning": convRun = truthy(raw); updateInfeed(); updateHealth(); break; + case "InfeedStarved": infeedStarved = truthy(raw); updateInfeed(); updateHealth(); break; + case "EStopHealthy": eStop = truthy(raw); updateHealth(); break; + default: break; } - envelopeCountView.setText(String.valueOf(envelopes)); - frameCountView.setText(String.valueOf(frames)); - latestUpdateView.setText(payload.length() <= 12000 - ? payload - : payload.substring(0, 12000) + "\n... truncated for TV rendering ..."); } - private void scheduleReconnect() { - if (manualDisconnect || lifecycleStopped || reconnectRunnable != null) { - return; - } - long base = Math.min(30000L, 1000L << Math.min(reconnectAttempt, 5)); - long delay = base + random.nextInt(500); - reconnectAttempt += 1; - reconnects += 1; - reconnectCountView.setText(String.valueOf(reconnects)); - setStatus("Retry in " + ((delay + 999) / 1000) + "s", false, true); - reconnectRunnable = () -> { - reconnectRunnable = null; - connect(); - }; - mainHandler.postDelayed(reconnectRunnable, delay); + private static boolean truthy(String s) { return "true".equalsIgnoreCase(s) || "1".equals(s); } + + private void updateRateDelta(float bpm) { + if (rateValue != null) rateValue.setText(String.format(Locale.US, "%.0f", bpm)); + if (rateBullet != null) rateBullet.setValue(bpm); + if (rateDelta == null) return; + int delta = Math.round(bpm - TARGET_BPM); + boolean ok = delta >= 0; + rateDelta.setText((ok ? "+" : "") + delta + " BPM"); + rateDelta.setTextColor(ok ? GOOD : COPPER); } - private void cancelReconnect() { - if (reconnectRunnable != null) { - mainHandler.removeCallbacks(reconnectRunnable); - reconnectRunnable = null; - } + private void updateTally() { + if (splitBar == null) return; + long o = Math.max(0, overfill), u = Math.max(0, underfill), c = Math.max(0, cap); + long t = o + u + c; + if (t > 0) splitBar.set(new float[]{(float) o / t, (float) u / t, (float) c / t}, + new int[]{COPPER, IRIS, CARTON}); + if (rejOver != null) rejOver.setText(commas(o)); + if (rejUnder != null) rejUnder.setText(commas(u)); + if (rejCap != null) rejCap.setText(commas(c)); } - private void closeCurrentSocket(int code, String reason) { - generation += 1; - WebSocket current = webSocket; - webSocket = null; - if (current != null && !current.close(code, reason)) { - current.cancel(); + private void updateFiller(String state) { + running = "RUNNING".equalsIgnoreCase(state); + String up = state == null ? "" : state.toUpperCase(Locale.US); + boolean fault = up.contains("STOP") || up.contains("FAULT"); + int color = running ? GOOD : (fault ? DANGER : SAFETY); + if (fillerBar != null) fillerBar.setBackgroundColor(color); + tileState(fillerTile, color, !running); + if (statusWord != null) statusWord.setText(pretty(state).toUpperCase(Locale.US)); + if (statusDotBlock != null) statusDotBlock.setBackgroundColor(color); + if (statusSub != null) statusSub.setText(running ? "Filler synchronized · infeed OK" + : (up.contains("PRESSURE") ? "Holding CO₂ head pressure" + : (up.contains("STARV") ? "Waiting on infeed" : "Filler halted"))); + TextView m = firstView("FillerMetric"); + if (m != null) m.setText(pretty(state)); + // the capper runs in lockstep with the filler; give tile C the same live state anatomy as A/B + if (capperBar != null) capperBar.setBackgroundColor(color); + TextView cm = firstView("CapperMetric"); + if (cm != null) cm.setText(running ? "Capping" : (fault ? "Stopped" : "Paused")); + } + + private void updateInfeed() { + boolean fault = !convRun; + int color = infeedStarved ? SAFETY : (fault ? DANGER : GOOD); + if (infeedBar != null) infeedBar.setBackgroundColor(color); + tileState(infeedTile, color, infeedStarved || fault); + TextView m = firstView("InfeedMetric"); + if (m != null) m.setText(fault ? "Stopped" : (infeedStarved ? "Starved" : "Feeding")); + } + + private void updateHealth() { + setClause(healthEStop, eStop, "E-stop healthy", "E-STOP OPEN"); + setClause(healthConv, convRun, "Conveyor running", "Conveyor stopped"); + setClause(healthInfeed, !infeedStarved, "Infeed OK", "Infeed starved"); + boolean fault = !eStop || !convRun || infeedStarved; + if (healthStripe != null) healthStripe.setBackgroundColor(fault ? DANGER : GOOD); + } + + private void setClause(TextView t, boolean ok, String good, String bad) { + if (t == null) return; + t.setText(ok ? good : bad); + t.setTextColor(ok ? MUTED : DANGER); + t.setTypeface(ok ? Typeface.DEFAULT : Typeface.DEFAULT_BOLD); + } + + private void tileState(LinearLayout tile, int color, boolean alert) { + if (tile == null) return; + GradientDrawable g = new GradientDrawable(); + g.setColor(alert ? (color == DANGER ? TINT_DANGER : TINT_SAFETY) : PANEL); + g.setStroke(dp(alert ? 2 : 1), alert ? color : LINE); + tile.setBackground(g); + } + + private static String pretty(String s) { + if (s == null || s.isEmpty()) return "—"; + String t = s.replace('_', ' ').toLowerCase(Locale.US); + return Character.toUpperCase(t.charAt(0)) + t.substring(1); + } + + private String format(String name, Object value, TextView view) { + switch (name) { + case "GoodBottleCount": + case "RejectCount": + case "OverfillRejectCount": + case "UnderfillRejectCount": + return commas(value); + case "CapRejectCount": + return commas(value); + case "ValveTrackingCount": { + Object tag = view.getTag(); + return (tag instanceof String ? (String) tag : "") + commas(value); + } + case "LineSpeedBpm": + case "ConveyorSpeedPct": + return String.format(Locale.US, "%.0f", toDouble(value)); + case "CO2Volumes": + return String.format(Locale.US, "%.2f", toDouble(value)); + case "FillerState": + case "InfeedMetric": + case "FillerMetric": + return pretty(String.valueOf(value)); + case "CapperMetric": + return String.valueOf(value); + default: // OEE, Availability, Performance, Quality, ProductTempC, BowlLevelPct, pressure, volume + return String.format(Locale.US, "%.1f", toDouble(value)); } } - private void setStatus(String text, boolean connected, boolean connecting) { - statusView.setText(text); - int fill = connected - ? Color.rgb(29, 131, 72) - : connecting ? Color.rgb(154, 103, 0) : Color.rgb(161, 43, 49); - statusView.setBackground(rounded(fill, Color.TRANSPARENT, 0, 28)); + private static double toDouble(Object value) { + if (value instanceof Number) return ((Number) value).doubleValue(); + try { return Double.parseDouble(String.valueOf(value)); } catch (NumberFormatException e) { return 0.0; } + } + + private static String commas(Object value) { return String.format(Locale.US, "%,d", (long) toDouble(value)); } + + // ---------------------------------------------------------------- transport + + private void connect() { + if (!validGatewayUrl(gatewayUrl)) return; + cancelReconnect(); + closeCurrentSocket(1001, "Reconnecting"); + manualDisconnect = false; + int gen = ++generation; + post(() -> setConn("CONNECTING", SAFETY)); + Request request = new Request.Builder().url(gatewayUrl).header("Origin", APP_ORIGIN).build(); + webSocket = client.newWebSocket(request, new GembaListener(gen)); } - private void setLastMessage(String type) { - lastMessageView.setText("Last message: " + type); + private final class GembaListener extends WebSocketListener { + private final int gen; + private GembaListener(int gen) { this.gen = gen; } + private boolean active() { return gen == generation && !lifecycleStopped; } + + @Override public void onOpen(WebSocket s, Response r) { if (!active()) { s.cancel(); return; } s.send(helloFrame()); } + + @Override public void onMessage(WebSocket s, String text) { + if (!active()) return; + try { + JSONObject m = new JSONObject(text); + String type = m.optString("type", "unknown"); + if ("welcome".equals(type)) { reconnectAttempt = 0; s.send(subscribeFrame()); post(() -> setConn("LIVE", GOOD)); } + else if ("updates".equals(type)) { JSONArray fr = m.optJSONArray("frames"); post(() -> handleUpdates(fr)); } + } catch (JSONException ignored) { } + } + + @Override public void onClosing(WebSocket s, int code, String reason) { s.close(code, reason); } + @Override public void onClosed(WebSocket s, int code, String reason) { if (active()) post(() -> { setConn("STALE", DANGER); scheduleReconnect(); }); } + @Override public void onFailure(WebSocket s, Throwable e, Response r) { if (active()) post(() -> { setConn("STALE", DANGER); scheduleReconnect(); }); } } - private void setLastError(String error) { - lastErrorView.setText("Last error: " + (error == null ? "Unknown" : error)); + private String helloFrame() { + try { return new JSONObject().put("type", "hello").put("protocolVersion", PROTOCOL_VERSION).toString(); } + catch (JSONException e) { throw new IllegalStateException(e); } } - private void post(Runnable runnable) { - mainHandler.post(runnable); + private String subscribeFrame() { + try { + JSONArray req = new JSONArray(); + for (String c : CAPABILITIES) req.put(c); + return new JSONObject().put("type", "subscribe").put("protocolVersion", PROTOCOL_VERSION).put("capabilities", req).toString(); + } catch (JSONException e) { throw new IllegalStateException(e); } } - private boolean validGatewayUrl(String value) { - return value.startsWith("ws://") || value.startsWith("wss://"); + private void scheduleReconnect() { + if (reconnectRunnable != null || manualDisconnect || lifecycleStopped) return; + long delay = Math.min(15000, 2000L * (long) Math.pow(2, Math.min(reconnectAttempt++, 3))); + reconnectRunnable = () -> { reconnectRunnable = null; if (!manualDisconnect && !lifecycleStopped) connect(); }; + mainHandler.postDelayed(reconnectRunnable, delay); } - private int dp(int value) { - return Math.round(value * getResources().getDisplayMetrics().density); + private void cancelReconnect() { + if (reconnectRunnable != null) { mainHandler.removeCallbacks(reconnectRunnable); reconnectRunnable = null; } } - private LinearLayout.LayoutParams matchWrap() { - return new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); + private void closeCurrentSocket(int code, String reason) { + WebSocket cur = webSocket; + webSocket = null; + if (cur != null) cur.close(code, reason); } - private LinearLayout.LayoutParams match(int height) { - return new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, height); + private boolean validGatewayUrl(String url) { return url != null && (url.startsWith("ws://") || url.startsWith("wss://")); } + + // ---------------------------------------------------------------- misc + + private void startClock() { + Runnable tick = new Runnable() { + @Override public void run() { + if (clockView != null) clockView.setText(clockFmt.format(new Date())); + if (webSocket != null && lastDataAt != 0 && SystemClock.elapsedRealtime() - lastDataAt > 12000 + && !lifecycleStopped && !manualDisconnect) { + setConn("STALE", DANGER); + closeCurrentSocket(1001, "stalled"); + scheduleReconnect(); + lastDataAt = 0; + } + mainHandler.postDelayed(this, 1000); + } + }; + mainHandler.post(tick); } - private LinearLayout.LayoutParams wrap() { - return new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ); + private void setConn(String label, int color) { + if (statusView != null) { statusView.setText(label); statusView.setTextColor(color); } + if (statusDot != null) statusDot.setBackground(dot(color)); } - private LinearLayout.LayoutParams weighted(float weight) { - return new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, weight); + private void post(Runnable action) { mainHandler.post(action); } + + // ---- typography ---- + private TextView micro(String s, int color, int sizeSp) { + TextView t = new TextView(this); + t.setText(s); + t.setTextSize(sizeSp); + t.setTypeface(Typeface.MONOSPACE, Typeface.BOLD); + t.setLetterSpacing(0.06f); + t.setTextColor(color); + t.setMaxLines(1); + return t; + } + private TextView val(String s, int color, int sizeSp) { + TextView t = new TextView(this); + t.setText(s); + t.setTextSize(sizeSp); + t.setTypeface(cond); + t.setTextColor(color); + t.setMaxLines(1); + t.setIncludeFontPadding(false); + return t; + } + private TextView unit(String s) { TextView t = new TextView(this); t.setText(s); t.setTextSize(12); t.setTextColor(MUTED); t.setMaxLines(1); return t; } + private TextView unitOn(String s, int color) { TextView t = new TextView(this); t.setText(s); t.setTextSize(12); t.setTextColor(color); t.setMaxLines(1); return t; } + private TextView sub(String s) { TextView t = new TextView(this); t.setText(s); t.setTextSize(11); t.setTextColor(MUTED); t.setMaxLines(1); return t; } + private View dotSep() { TextView t = new TextView(this); t.setText(" · "); t.setTextSize(11); t.setTextColor(LINE); return t; } + + // ---- primitives ---- + private LinearLayout row(int gravity) { LinearLayout l = new LinearLayout(this); l.setOrientation(LinearLayout.HORIZONTAL); l.setGravity(gravity); l.setBaselineAligned(false); return l; } + private LinearLayout col() { LinearLayout l = new LinearLayout(this); l.setOrientation(LinearLayout.VERTICAL); return l; } + private View rule(int hDp, int color) { View v = new View(this); v.setBackgroundColor(color); v.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(hDp))); return v; } + private View gap(int hDp) { View v = new View(this); v.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(hDp))); return v; } + private View vrule(int color) { View v = new View(this); v.setBackgroundColor(color); v.setLayoutParams(new LinearLayout.LayoutParams(dp(1), LinearLayout.LayoutParams.MATCH_PARENT)); return v; } + private View spacerW() { return new View(this); } + private View bar(int wDp, int hDp, int color) { return bar(wDp, hDp, color, 0); } + private View bar(int wDp, int hDp, int color, int leftDp) { View v = new View(this); v.setBackgroundColor(color); LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(dp(wDp), dp(hDp)); lp.leftMargin = dp(leftDp); v.setLayoutParams(lp); return v; } + + private GradientDrawable panelBg(int stroke, int strokeDp) { + GradientDrawable g = new GradientDrawable(); + g.setColor(PANEL); + g.setStroke(dp(strokeDp), stroke); + return g; } + private GradientDrawable dot(int color) { GradientDrawable g = new GradientDrawable(); g.setShape(GradientDrawable.OVAL); g.setColor(color); return g; } + private GradientDrawable hazard(int color) { GradientDrawable g = new GradientDrawable(); g.setColor(color); return g; } + + private LinearLayout.LayoutParams band(int hDp) { return new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(hDp)); } + private LinearLayout.LayoutParams mw() { return new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); } + private LinearLayout.LayoutParams mh() { return new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT); } + private LinearLayout.LayoutParams wc() { return new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); } + private LinearLayout.LayoutParams wcC() { LinearLayout.LayoutParams lp = wc(); lp.gravity = Gravity.CENTER_HORIZONTAL; return lp; } + private LinearLayout.LayoutParams wcBottom() { LinearLayout.LayoutParams lp = wc(); lp.bottomMargin = dp(3); return lp; } + private LinearLayout.LayoutParams topM(int t) { LinearLayout.LayoutParams lp = mw(); lp.topMargin = dp(t); return lp; } + private LinearLayout.LayoutParams rowW(int top, int hDp) { LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(hDp)); lp.topMargin = top; return lp; } + private LinearLayout.LayoutParams rowW(int top, int hDp, float weight) { LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, weight); lp.topMargin = top; return lp; } + private LinearLayout.LayoutParams weight(float w) { return new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, w); } + private LinearLayout.LayoutParams weightFill(float w) { return new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, w); } + private LinearLayout.LayoutParams weightFillM(float w, int rightDp) { LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, w); lp.rightMargin = rightDp; return lp; } + + private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); } + private int dp(float v) { return Math.round(v * getResources().getDisplayMetrics().density); } } diff --git a/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/TankGaugeView.java b/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/TankGaugeView.java new file mode 100644 index 0000000..7b93d98 --- /dev/null +++ b/experiments/gemba/tv/google-tv-gemba/app/src/main/java/dev/edgecommons/gembatv/TankGaugeView.java @@ -0,0 +1,94 @@ +package dev.edgecommons.gembatv; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.RectF; +import android.view.View; + +/** + * A flat filler-bowl tank: a square-cornered shell filled to a level from the bottom, with a single + * meniscus line, faint 25/50/75 ticks, and a dashed low-level threshold. No gradient, no wave, and + * no text — the numeric value lives beside the tank in the board's standard measure style, so the + * bowl reads like every other measure. The liquid is a declared material colour; it turns caution + * amber only when the level drops below the low threshold. + */ +public final class TankGaugeView extends View { + private static final int SHELL = Color.rgb(185, 181, 170); + private static final int TICK = Color.rgb(217, 216, 209); + private static final int LIQUID = Color.rgb(74, 127, 181); + private static final int MENISCUS = Color.rgb(46, 95, 145); + private static final int SAFETY = Color.rgb(234, 181, 69); + + private final Paint shell = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint tick = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint liquid = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint meniscus = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint lowLine = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint lowText = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF body = new RectF(); + + private float level = -1f; + private float low = 35f; + + public TankGaugeView(Context c) { + super(c); + shell.setStyle(Paint.Style.STROKE); + shell.setColor(SHELL); + tick.setStyle(Paint.Style.STROKE); + tick.setColor(TICK); + liquid.setStyle(Paint.Style.FILL); + meniscus.setStyle(Paint.Style.STROKE); + meniscus.setColor(MENISCUS); + lowLine.setStyle(Paint.Style.STROKE); + lowLine.setColor(SAFETY); + lowText.setColor(SAFETY); + lowText.setTextSize(dp(9)); + } + + public void setLevel(float pct) { + level = Math.max(0, Math.min(100, pct)); + invalidate(); + } + + public void setLowThreshold(float pct) { low = pct; } + + @Override + protected void onDraw(Canvas cv) { + float pad = dp(2); + float left = pad, right = getWidth() - pad, top = pad, bottom = getHeight() - pad; + body.set(left, top, right, bottom); + + // liquid + if (level >= 0) { + boolean lowLvl = level < low; + liquid.setColor(lowLvl ? SAFETY : LIQUID); + float fillTop = bottom - (level / 100f) * (bottom - top); + cv.drawRect(left, fillTop, right, bottom, liquid); + meniscus.setStrokeWidth(dp(2)); + cv.drawLine(left, fillTop, right, fillTop, meniscus); + } + + // 25/50/75 ticks + tick.setStrokeWidth(dp(1)); + for (int i = 1; i < 4; i++) { + float y = bottom - i / 4f * (bottom - top); + cv.drawLine(left, y, right, y, tick); + } + + // low threshold + lowLine.setStrokeWidth(dp(1.5f)); + lowLine.setPathEffect(new DashPathEffect(new float[]{dp(4), dp(3)}, 0)); + float ly = bottom - (low / 100f) * (bottom - top); + cv.drawLine(left, ly, right, ly, lowLine); + cv.drawText("LOW", left + dp(3), ly - dp(4), lowText); + + // shell + shell.setStrokeWidth(dp(1.5f)); + cv.drawRect(body, shell); + } + + private float dp(float v) { return v * getResources().getDisplayMetrics().density; } +} diff --git a/experiments/gemba/tv/tizen-gemba/app.js b/experiments/gemba/tv/tizen-gemba/app.js index f68be13..bf16566 100644 --- a/experiments/gemba/tv/tizen-gemba/app.js +++ b/experiments/gemba/tv/tizen-gemba/app.js @@ -1,198 +1,249 @@ -/* global WebSocket, document, window */ - +/* Dallas Line 2 packaging OEE board — LIVE. + * + * Conservative ES5 for the 2019 Tizen web runtime AND ordinary browsers. Replaces the mockup's + * synthetic scenario with the real gateway app-WebSocket: protocol-v1 hello/subscribe, then it + * binds incoming `signals` frames to the dashboard's [data-signal] tiles and drives the pallet + * grid, jam state, motor-current trend, and OEE strip from live values. + * + * Gateway URL: window.GEMBA_TV_CONFIG.gatewayUrl (config.js, used by the packaged TV app) or, + * when served in a browser at /apps/{id}/, derived from location. + */ (function () { "use strict"; var config = window.GEMBA_TV_CONFIG || {}; - var protocolVersion = config.protocolVersion || 1; - var capabilities = config.capabilities || ["fleet", "events", "signals", "attributes", "alarms"]; - var storageKey = "edgecommons.gemba.gatewayUrl"; + var capabilities = config.capabilities || ["signals", "alarms"]; var socket = null; var reconnectTimer = null; - var manualDisconnect = false; - var reconnectAttempt = 0; - var reconnects = 0; - var envelopes = 0; - var frames = 0; - var rateWindowStartedAt = Date.now(); - var rateWindowEnvelopes = 0; - - var status = document.getElementById("status"); - var gatewayInput = document.getElementById("gateway-url"); - var envelopeCount = document.getElementById("envelope-count"); - var frameCount = document.getElementById("frame-count"); - var messageRate = document.getElementById("message-rate"); - var reconnectCount = document.getElementById("reconnect-count"); - var lastMessage = document.getElementById("last-message"); - var lastError = document.getElementById("last-error"); - var latestUpdate = document.getElementById("latest-update"); - - function setStatus(text, state) { - status.textContent = text; - status.className = "status " + state; - } - - function storedGatewayUrl() { - try { - return window.localStorage.getItem(storageKey) || config.gatewayUrl || ""; - } catch (error) { - lastError.textContent = "Could not read saved gateway URL: " + error.message; - return config.gatewayUrl || ""; - } + var history = []; + var latest = {}; + var lastData = 0; + function nowMs() { return new Date().getTime(); } + + function appIdFromPath() { + var m = (location.pathname || "").match(/\/apps\/([^\/]+)\//); + return m ? m[1] : "tv-board"; } - function saveGatewayUrl(value) { - try { - window.localStorage.setItem(storageKey, value); - } catch (error) { - lastError.textContent = "Could not persist gateway URL: " + error.message; - } + function gatewayUrl() { + if (config.gatewayUrl) return config.gatewayUrl; + var proto = location.protocol === "https:" ? "wss:" : "ws:"; + return proto + "//" + location.host + "/apps/" + appIdFromPath() + "/ws"; } - function validGatewayUrl(value) { - return /^wss?:\/\/[^\s]+$/i.test(value); + function byId(id) { return document.getElementById(id); } + function text(id, value) { var el = byId(id); if (el) el.textContent = value; } + function pad(v) { return v < 10 ? "0" + v : String(v); } + function commas(v) { return String(v).replace(/\B(?=(\d{3})+(?!\d))/g, ","); } + function num(v) { return typeof v === "number" ? v : parseFloat(v); } + + function setConnState(state, detail) { + var el = byId("source-state"); + if (el) el.setAttribute("data-state", state); + if (detail) text("source-detail", detail); } - function scheduleReconnect() { - var delay; - if (manualDisconnect || reconnectTimer !== null) { - return; - } - delay = Math.min(30000, 1000 * Math.pow(2, Math.min(reconnectAttempt, 5))); - delay += Math.floor(Math.random() * 500); - reconnectAttempt += 1; - reconnects += 1; - reconnectCount.textContent = String(reconnects); - setStatus("Retry in " + Math.ceil(delay / 1000) + "s", "connecting"); - reconnectTimer = window.setTimeout(function () { - reconnectTimer = null; - connect(); - }, delay); - } - - function disconnect(manual) { - manualDisconnect = manual; - if (reconnectTimer !== null) { - window.clearTimeout(reconnectTimer); - reconnectTimer = null; + // --- pallet grid (24-cell layer view driven by PalletCaseCount) --- + function buildPallet() { + var grid = byId("pallet-grid"); + if (!grid || grid.childNodes.length) return; + for (var i = 0; i < 24; i += 1) { + var cell = document.createElement("span"); + cell.textContent = pad(i + 1); + grid.appendChild(cell); } - if (socket !== null) { - try { - socket.close(1000, manual ? "user disconnect" : "reconnect"); - } catch (error) { - lastError.textContent = error.message; + } + function renderPallet(palletCases) { + var loaded = palletCases <= 0 ? 0 : (((palletCases - 1) % 24) + 1); + var grid = byId("pallet-grid"); + if (grid) { + var cells = grid.getElementsByTagName("span"); + for (var i = 0; i < cells.length; i += 1) { + cells[i].className = i < loaded ? "is-loaded" : (i === loaded ? "is-next" : ""); } - socket = null; - } - if (manual) { - setStatus("Disconnected", "disconnected"); } + var pct = Math.min(100, Math.round((palletCases / 120) * 100)); + text("pallet-cases", palletCases + " / 120"); + text("pallet-percent", pct + "%"); + var bar = byId("pallet-progress-bar"); + if (bar) bar.style.width = pct + "%"; + var layer = Math.min(5, Math.floor((loaded - 1) / (24 / 5)) + 1); + if (layer > 0) { text("pallet-layer", layer + " / 5"); text("pallet-layer2", layer + " / 5"); } } - function send(message) { - if (socket && socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify(message)); + // --- motor-current trend --- + function drawCurrent() { + var width = 600, height = 128, low = 3.5, high = 10.5, path = "", i; + for (i = 0; i < history.length; i += 1) { + var x = history.length === 1 ? 0 : (i / (history.length - 1)) * width; + var y = height - ((history[i] - low) / (high - low)) * height; + path += (i === 0 ? "M" : " L") + x.toFixed(1) + " " + y.toFixed(1); } + var line = byId("current-line"), area = byId("current-area"); + if (line) line.setAttribute("d", path); + if (area) area.setAttribute("d", path + " L" + width + " " + height + " L0 " + height + " Z"); } - function updateRate() { - var now = Date.now(); - var elapsed = (now - rateWindowStartedAt) / 1000; - if (elapsed >= 1) { - messageRate.textContent = (rateWindowEnvelopes / elapsed).toFixed(1) + " Hz"; - rateWindowStartedAt = now; - rateWindowEnvelopes = 0; - } + // --- jam / running board state --- + function renderState(jammed, state) { + var board = byId("board"); + if (board) board.className = jammed ? "board is-jammed" : "board"; + var pm = byId("packer-machine"); + if (pm) pm.className = jammed ? "machine machine--alert" : "machine machine--good"; + text("status-text", jammed ? "BLOCKED · PACKER JAM" : "RUNNING CLEAN"); + text("status-detail", jammed ? "Robot cell discharge photoeye held" : "Case packer synchronized with palletizer"); + text("packer-state", jammed ? "Jammed" : "Running"); + text("risk-chip", jammed ? "ACTION NOW" : "LOW RISK"); + text("jam-status", jammed ? "BLOCKED" : "CLEAR"); + text("attention-text", jammed + ? "Clear case at packer discharge and inspect guide rail" + : "Carton magazine refill scheduled"); } - function handleMessage(event) { - var message; - try { - message = JSON.parse(event.data); - } catch (error) { - lastError.textContent = "Invalid JSON: " + error.message; - return; + // signal -> how to render its [data-signal] tile text + var FORMAT = { + CaseRateCpm: function (v) { return num(v).toFixed(1); }, + GoodCaseCount: function (v) { return commas(Math.floor(num(v))); }, + CaseRejectCount: function (v) { return commas(Math.floor(num(v))); }, + PackerMotorCurrentA: function (v) { return num(v).toFixed(1); }, + VisionPassPct: function (v) { return num(v).toFixed(1) + "%"; }, + GlueTempC: function (v) { return String(Math.round(num(v))); }, + CaseWeightKg: function (v) { return num(v).toFixed(2); }, + CartonMagazinePct: function (v) { return Math.round(num(v)) + "% remaining"; }, + JamStatus: function (v) { return (v === true || v === "true") ? "BLOCKED" : "CLEAR"; }, + LabelCode: function (v) { return String(v); }, + PalletizerState: function (v) { return String(v); }, + OEE: function (v) { return num(v).toFixed(1); }, + Availability: function (v) { return num(v).toFixed(1); }, + Performance: function (v) { return num(v).toFixed(1); }, + Quality: function (v) { return num(v).toFixed(1); } + }; + + function applyTile(name, value) { + var fmt = FORMAT[name]; + var out = fmt ? fmt(value) : String(value); + var els = document.querySelectorAll('[data-signal="' + name + '"]'); + for (var i = 0; i < els.length; i += 1) { + els[i].textContent = out; + els[i].className = (els[i].className.replace(/\bvalue-updated\b/, "") + " value-updated").replace(/^\s+/, ""); } + } - lastMessage.textContent = new Date().toLocaleTimeString() + " - " + message.type; - if (message.type === "welcome") { - reconnectAttempt = 0; - setStatus("Live", "connected"); - send({ - type: "subscribe", - protocolVersion: protocolVersion, - capabilities: capabilities - }); - return; + function ingest(name, value) { + if (value === null || typeof value === "undefined" || !name) return; + lastData = nowMs(); + latest[name] = value; + applyTile(name, value); + + if (name === "PackerMotorCurrentA") { + history.push(num(value)); + if (history.length > 54) history.shift(); + drawCurrent(); + } else if (name === "PalletCaseCount") { + renderPallet(Math.floor(num(value))); + } else if (name === "JamStatus" || name === "PalletizerState") { + var jammed = latest.JamStatus === true || latest.JamStatus === "true" + || latest.PalletizerState === "BLOCKED"; + renderState(jammed, latest.PalletizerState); + } else if (name === "CaseRateCpm") { + var d = num(value) - 28; + text("rate-delta", (d >= 0 ? "+" : "") + d.toFixed(1)); } + } - if (message.type === "updates") { - envelopes += 1; - rateWindowEnvelopes += 1; - frames += message.frames && message.frames.length ? message.frames.length : 0; - envelopeCount.textContent = String(envelopes); - frameCount.textContent = String(frames); - latestUpdate.textContent = JSON.stringify(message, null, 2); - updateRate(); - return; + // The snapshot frame (type "signals") carries {name, latest}; the live delta frame + // (type "signal") carries {signal, point:{value}} where `signal` is the name string + // (or a {name} object). Read all forms so DELTAS bind, not just the initial snapshot. + // Snapshot series carry the short display name; delta updates carry `signal` as the CHANNEL PATH, + // which differs per adapter — modbus: bare name ("CartonMagazinePct"); OPC UA: full nodeId + // ("GGCommonsTest.Device1.Live.CaseWeightKg" / "Line1.LineSpeedBpm"); telemetry OEE: topic + // ("gemba/oee/availability"). Normalize all of these to the tile's data-signal name. + var OEE_TOPIC = { availability: "Availability", overall: "OEE", performance: "Performance", quality: "Quality" }; + function normSignal(s) { + if (!s) return s; + if (s.indexOf("gemba/oee/") >= 0) { + var seg = s.split("/").pop(); + return OEE_TOPIC[seg] || seg; } + if (s.indexOf(".") >= 0) return s.split(".").pop(); // OPC UA nodeId -> last segment + return s; + } + function signalName(x) { + if (!x) return undefined; + if (x.name) return x.name; // snapshot series: short display name + if (x.signal && typeof x.signal === "object" && x.signal.name) return x.signal.name; + if (typeof x.signal === "string") return normSignal(x.signal); // delta: channel path + return x.id; + } - if (message.type === "error") { - lastError.textContent = (message.code || "gateway-error") + ": " + (message.message || event.data); + function handleFrame(frame) { + if (!frame) return; + if (frame.type === "signals") { + var series = frame.series || []; + for (var i = 0; i < series.length; i += 1) { + ingest(signalName(series[i]), series[i].latest); + } + } else if (frame.type === "signal") { + var ups = frame.updates || []; + for (var j = 0; j < ups.length; j += 1) { + var u = ups[j]; + ingest(signalName(u), u.point ? u.point.value : u.value); + } } } - function connect() { - var gatewayUrl = gatewayInput.value.replace(/^\s+|\s+$/g, ""); - if (!validGatewayUrl(gatewayUrl)) { - lastError.textContent = "Enter a ws:// or wss:// gateway URL."; - gatewayInput.focus(); - return; - } + // --- transport --- + function send(obj) { if (socket && socket.readyState === 1) socket.send(JSON.stringify(obj)); } - disconnect(false); - manualDisconnect = false; - saveGatewayUrl(gatewayUrl); - setStatus("Connecting", "connecting"); - lastError.textContent = "None"; - - try { - socket = new WebSocket(gatewayUrl); - } catch (error) { - lastError.textContent = error.message; - scheduleReconnect(); - return; + function handleMessage(event) { + var msg; + try { msg = JSON.parse(event.data); } catch (e) { return; } + if (msg.type === "welcome") { + setConnState("healthy", "Bridge live · " + (msg.appId || appIdFromPath())); + send({ type: "subscribe", protocolVersion: 1, capabilities: capabilities }); + } else if (msg.type === "subscribed") { + setConnState("healthy", "Live · max " + (msg.maxUpdateHz || 30) + "/s"); + } else if (msg.type === "updates") { + var frames = msg.frames || []; + for (var i = 0; i < frames.length; i += 1) handleFrame(frames[i]); + } else if (msg.type === "error") { + setConnState("warn", msg.code || "error"); } + } - socket.onopen = function () { - setStatus("Handshaking", "connecting"); - send({ type: "hello", protocolVersion: protocolVersion }); - }; + function connect() { + setConnState("warn", "Connecting…"); + try { socket = new WebSocket(gatewayUrl()); } + catch (e) { scheduleReconnect(); return; } + socket.onopen = function () { send({ type: "hello", protocolVersion: 1 }); }; socket.onmessage = handleMessage; - socket.onerror = function () { - lastError.textContent = "WebSocket error. Check the gateway log for the Origin value and rejection reason."; - }; - socket.onclose = function (event) { - socket = null; - if (!manualDisconnect) { - lastError.textContent = "Closed: " + event.code + (event.reason ? " - " + event.reason : ""); - scheduleReconnect(); - } - }; - } - - document.getElementById("app-origin").textContent = window.location.origin || "(not exposed)"; - gatewayInput.value = storedGatewayUrl(); - document.getElementById("connect-button").addEventListener("click", connect); - document.getElementById("disconnect-button").addEventListener("click", function () { disconnect(true); }); - window.addEventListener("online", function () { if (!manualDisconnect) { connect(); } }); - window.addEventListener("offline", function () { setStatus("Network offline", "disconnected"); }); - document.addEventListener("visibilitychange", function () { - if (!document.hidden && (!socket || socket.readyState > WebSocket.OPEN) && !manualDisconnect) { - connect(); + socket.onclose = function () { socket = null; setConnState("warn", "Reconnecting…"); scheduleReconnect(); }; + socket.onerror = function () { setConnState("warn", "Socket error"); }; + } + + function scheduleReconnect() { + if (reconnectTimer) return; + reconnectTimer = window.setTimeout(function () { reconnectTimer = null; connect(); }, 3000); + } + + function tickClock() { + var d = new Date(); + text("clock", pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())); + } + + // Stalled-socket watchdog: a WebSocket can go silent without a clean onclose (e.g. the server + // bounced). If the socket looks open but no data has bound for 12s, force a reconnect so the UI + // reflects reality ("Reconnecting…") instead of a stale "Live". + function watchdog() { + if (socket && socket.readyState === 1 && lastData && (nowMs() - lastData) > 12000) { + setConnState("warn", "Stalled — reconnecting…"); + try { socket.close(); } catch (e) { /* onclose triggers reconnect */ } } - }); - window.setInterval(updateRate, 1000); + } + buildPallet(); + tickClock(); + window.setInterval(tickClock, 1000); + window.setInterval(watchdog, 5000); connect(); }()); diff --git a/experiments/gemba/tv/tizen-gemba/config.js b/experiments/gemba/tv/tizen-gemba/config.js index 34757c4..762a8ad 100644 --- a/experiments/gemba/tv/tizen-gemba/config.js +++ b/experiments/gemba/tv/tizen-gemba/config.js @@ -1,11 +1,12 @@ /* global window */ - +/* Packaged Tizen build: explicit LAN gateway URL for the dallas-site console. The packaged app's + * Origin is file://, which the console's tv-board app registry must allow. Update the host/port if + * the harness console is exposed elsewhere. */ (function () { "use strict"; - window.GEMBA_TV_CONFIG = { - gatewayUrl: "ws://192.168.1.224:18445/apps/tv-board/ws", + gatewayUrl: "ws://192.168.1.224:8080/apps/tv-board/ws", protocolVersion: 1, - capabilities: ["fleet", "events", "signals", "attributes", "alarms"] + capabilities: ["signals", "alarms"] }; }()); diff --git a/experiments/gemba/tv/tizen-gemba/index.html b/experiments/gemba/tv/tizen-gemba/index.html index d7c9949..6d014cc 100644 --- a/experiments/gemba/tv/tizen-gemba/index.html +++ b/experiments/gemba/tv/tizen-gemba/index.html @@ -3,62 +3,106 @@ - EdgeCommons Gemba TV experiment + Dallas Line 2 — Packaging OEE board -
-
-

Tizen WebSocket feasibility client

-

Dallas Gemba Board

-
-
Starting
-
+
+
+
+ + BOTTLES R USDALLAS · PACKAGING HALL +
+
LINE02
+
+ Connecting… + + SHIFT A · LIVE +
+
-
-
-
- Update envelopes - 0 -
-
- Current rate - 0.0 Hz -
-
- Frames observed - 0 -
-
- Reconnects - 0 -
+
+
OEE--%
+
AVAILABILITY--%
+
PERFORMANCE--%
+
QUALITY--%
-
-
-

Connection

- - -
- - +
+
PACKAGING STATUSRUNNING CLEANCase packer synchronized with palletizer
+
ACTIVE ORDERSPRK-LIME-355 · 24 PACKWO 71042 · Customer DC-04
+
ACTUAL RATE--CASES / MINTarget 28.0 · --
+
+ +
+
+
+
01 · PROCESS

Pack flow

All interlocks made
+
+
A
CASE ERECTORRunning62% blanks
+ +
B
ROBOTIC PACKERRunning6.2 A load
+ +
C
CASE SEALERRunning176 °C glue
+ +
D
LABEL + VISIONPassing99.4% verify
+ +
E
PALLETIZERBuildingLayer 4 / 5
+
+
+ +
+
+
02 · SHIFT

Throughput

live
+
+
--good cases
+
+
Good casesReject cases
+
Reject cases--Microstops7Blocked time--
+
+
+ +
+
03 · PACKER LOAD

Jam early warning

LOW RISK
+
+
--ANormal 5.4–7.1 A
+ + + 9.0 A JAM RISK + + + +
Jam switch CLEARPalletizer BUILDING
+
+
-
-
Application origin
-
Last message
None
-
Last error
None
-
-
-
-

Latest update

-
Waiting for the gateway...
-
+
+
V
VISION PASS--Target ≥ 99.0%
+
°
GLUE TEMPERATURE-- °CBand 172–180 °C
+
kg
CASE WEIGHT-- kg±0.08 kg
+
LABEL VERIFY--Grade A · readable
+
+ + +
-
-
Remote: use arrow keys to move focus and Enter to activate. Data delivery is capped at 30 Hz.
+
+
NEXT ATTENTIONCarton magazine refill scheduled--
+
OPC UA Kepware · palletizer1 MODBUS Host sim · casepacker1
+
+
diff --git a/experiments/gemba/tv/tizen-gemba/styles.css b/experiments/gemba/tv/tizen-gemba/styles.css index c501005..0f70df7 100644 --- a/experiments/gemba/tv/tizen-gemba/styles.css +++ b/experiments/gemba/tv/tizen-gemba/styles.css @@ -1,209 +1,167 @@ -* { - box-sizing: border-box; -} - -html, -body { - width: 100%; - min-height: 100%; - margin: 0; - background: #071923; - color: #f7fbff; - font-family: Arial, Helvetica, sans-serif; -} - -body { - padding: 54px 70px 38px; -} - -header { - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 8px solid #36c2b4; - padding-bottom: 26px; -} - -h1 { - margin: 2px 0 0; - font-size: 68px; - line-height: 1; -} - -h2 { - margin: 0 0 22px; - font-size: 34px; -} - -.eyebrow, -.label, -footer, -label, -dt { - color: #9eb5c2; -} - -.eyebrow { - margin: 0; - font-size: 22px; - text-transform: uppercase; - letter-spacing: 3px; -} - -.status { - min-width: 250px; - border-radius: 40px; - padding: 18px 30px; - text-align: center; - font-size: 30px; - font-weight: bold; -} - -.status.connected { - background: #1d8348; -} - -.status.connecting { - background: #9a6700; -} - -.status.disconnected { - background: #a12b31; -} - -.metrics, -.details, -.buttons { - display: flex; -} - -.metrics { - gap: 22px; - margin: 34px 0 26px; -} - -.metrics article { - width: 25%; - min-height: 150px; - border: 2px solid #244553; - border-radius: 16px; - background: #102c38; - padding: 24px; -} - -.metrics strong { - display: block; - margin-top: 12px; - color: #ffffff; - font-size: 52px; - font-variant-numeric: tabular-nums; -} - -.label { - font-size: 22px; -} - -.details { - gap: 24px; -} - -.details article { - min-height: 465px; - border: 2px solid #244553; - border-radius: 16px; - background: #0d2631; - padding: 28px; -} - -.connection-card { - width: 42%; -} - -.payload-card { - width: 58%; -} - -label { - display: block; - margin-bottom: 8px; - font-size: 20px; -} - -input, -button { - border: 3px solid transparent; - border-radius: 9px; - font-size: 22px; -} - -input { - width: 100%; - padding: 15px; - background: #ecf5f8; - color: #071923; -} - -.buttons { - gap: 14px; - margin: 18px 0 24px; -} - -button { - padding: 14px 22px; - background: #167b72; - color: white; - font-weight: bold; -} - -button:last-child { - background: #4d6570; -} - -input:focus, -button:focus { - outline: none; - border-color: #ffcf4a; - box-shadow: 0 0 0 5px rgba(255, 207, 74, .25); -} - -dl { - margin: 0; - font-size: 20px; -} - -dt { - float: left; - clear: left; - width: 190px; - margin: 0 16px 10px 0; -} - -dd { - min-height: 24px; - margin: 0 0 10px 206px; - word-break: break-all; -} - -pre { - height: 360px; - margin: 0; - overflow: auto; - white-space: pre-wrap; - word-break: break-word; - color: #d5edf2; - font: 18px/1.4 Consolas, monospace; -} - -footer { - margin-top: 21px; - font-size: 18px; - text-align: center; -} - -@media (max-width: 1280px) { - body { padding: 30px 40px 24px; } - h1 { font-size: 48px; } - .metrics strong { font-size: 38px; } - .details article { min-height: 390px; } - pre { height: 285px; } -} +:root { + --ink: #23233d; + --ink-2: #343653; + --paper: #f6f3eb; + --panel: #fffdf8; + --line: #d9d8d1; + --muted: #6f7080; + --iris: #5d69a8; + --copper: #d67a4e; + --carton: #c89552; + --safety: #eab545; + --good: #3f8f69; + --danger: #bd4d56; + --display: "Arial Narrow", "Roboto Condensed", "Aptos Narrow", sans-serif; + --body: Manrope, "Segoe UI", Arial, sans-serif; + --mono: "JetBrains Mono", Consolas, monospace; +} + +* { box-sizing: border-box; } +html, body { width: 100%; min-height: 100%; } +body { margin: 0; background: #d9d8d1; color: var(--ink); font-family: var(--body); -webkit-font-smoothing: antialiased; } +.board { min-height: 100vh; background: var(--paper); border-top: 8px solid var(--safety); overflow: hidden; } +.header { min-height: 100px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 14px 40px; background: var(--ink); color: white; } +.brand { display: flex; align-items: center; gap: 15px; } +.brand__mark { display: flex; align-items: end; gap: 4px; height: 38px; } +.brand__mark i { display: block; width: 8px; background: var(--safety); }.brand__mark i:nth-child(1) { height: 20px; }.brand__mark i:nth-child(2) { height: 29px; }.brand__mark i:nth-child(3) { height: 38px; background: var(--copper); } +.brand b { display: block; font: 700 24px/1 var(--display); letter-spacing: .14em; }.brand small { display: block; color: #b8b9c8; font: 600 11px/1 var(--mono); margin-top: 7px; letter-spacing: .1em; } +.line-id { display: flex; align-items: center; gap: 11px; border-left: 1px solid #4d4e69; border-right: 1px solid #4d4e69; padding: 0 35px; } +.line-id span { color: #b8b9c8; font: 700 12px/1 var(--mono); letter-spacing: .16em; writing-mode: vertical-rl; transform: rotate(180deg); }.line-id strong { font: 800 58px/.9 var(--display); } +.header__meta { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; gap: 8px 24px; text-align: right; } +.header__meta time { font: 600 25px/1 var(--mono); }.header__meta small { grid-column: 1 / -1; color: #b8b9c8; font: 600 10px/1 var(--mono); letter-spacing: .08em; } +.source-state { color: #74c398; font: 700 11px/1 var(--mono); letter-spacing: .07em; }.source-state i { display: inline-block; width: 8px; height: 8px; background: #74c398; border-radius: 50%; margin-right: 7px; box-shadow: 0 0 0 5px rgba(116,195,152,.12); } + +.status-band { display: grid; grid-template-columns: 1.12fr 1fr .68fr; min-height: 160px; border-bottom: 2px solid var(--ink); background: var(--panel); } +.status-band > div { padding: 25px 36px; display: flex; flex-direction: column; justify-content: center; } +.status-band > div + div { border-left: 1px solid var(--line); } +.status-band span { color: var(--muted); font: 700 10px/1 var(--mono); letter-spacing: .15em; }.status-band small { color: var(--muted); font-size: 12px; margin-top: 8px; } +.status-band__label strong { font: 800 45px/1 var(--display); letter-spacing: -.01em; margin-top: 8px; }.status-band__label strong::before { content: ""; display: inline-block; width: 14px; height: 30px; background: var(--good); margin-right: 14px; } +.status-band__sku b { font: 700 25px/1.2 var(--display); margin-top: 10px; } +.status-band__rate { background: var(--safety); color: var(--ink); }.status-band__rate span, .status-band__rate small { color: #584415; }.status-band__rate strong { font: 800 62px/.88 var(--display); margin-top: 8px; }.status-band__rate b { font: 700 12px/1 var(--mono); letter-spacing: .08em; margin-top: 7px; }.status-band__rate small i { font-style: normal; font-weight: 700; } + +.content { display: grid; grid-template-columns: minmax(0, 1fr) 440px; min-height: 710px; } +.operations { min-width: 0; padding: 24px 26px 18px 38px; border-right: 2px solid var(--ink); } +.section-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; } +.section-heading small { display: block; color: var(--muted); font: 700 9px/1 var(--mono); letter-spacing: .14em; }.section-heading h1, .section-heading h2 { margin: 5px 0 0; font: 750 25px/1 var(--display); }.section-heading em { align-self: center; color: var(--good); font: 700 10px/1 var(--mono); font-style: normal; letter-spacing: .06em; } +.flow-track { display: grid; grid-template-columns: 1fr 26px 1.18fr 26px 1fr 26px 1.15fr 26px 1fr; align-items: center; gap: 0; margin-top: 15px; } +.machine { position: relative; min-height: 95px; padding: 14px 13px 12px 46px; background: var(--panel); border: 1px solid var(--line); } +.machine__index { position: absolute; left: 13px; top: 13px; display: grid; place-items: center; width: 24px; height: 24px; background: var(--ink); color: white; font: 700 11px/1 var(--mono); } +.machine small, .machine em { display: block; color: var(--muted); font: 700 8px/1.25 var(--mono); font-style: normal; letter-spacing: .05em; }.machine b { display: block; font: 700 18px/1.4 var(--display); }.machine i { position: absolute; left: 0; right: 0; bottom: 0; height: 5px; background: var(--good); } +.flow-arrow { color: var(--muted); text-align: center; font: 300 31px/1 var(--display); } +.operation-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 18px; } +.panel { min-height: 270px; padding: 18px 20px; background: var(--panel); border: 1px solid var(--line); } +.ahead { color: var(--iris) !important; }.throughput-main { display: flex; align-items: baseline; gap: 11px; margin-top: 20px; }.throughput-main strong { font: 800 49px/.9 var(--display); }.throughput-main span { color: var(--muted); font-size: 11px; } +.plan-line { position: relative; height: 10px; margin-top: 22px; background: #e6e4de; overflow: visible; }.plan-line i { display: block; width: 97%; height: 100%; background: var(--iris); }.plan-line mark { position: absolute; left: 96.7%; top: -5px; width: 2px; height: 20px; background: var(--ink); } +.plan-labels { display: flex; justify-content: space-between; color: var(--muted); font: 600 8px/1 var(--mono); margin-top: 8px; } +.throughput-stats { display: grid; grid-template-columns: repeat(3,1fr); margin-top: 22px; padding-top: 13px; border-top: 1px solid var(--line); }.throughput-stats span + span { border-left: 1px solid var(--line); padding-left: 15px; }.throughput-stats small { display: block; color: var(--muted); font-size: 9px; }.throughput-stats b { display: block; font: 700 16px/1.6 var(--mono); } +.risk-chip { color: var(--good) !important; border: 1px solid #91bfa7; padding: 6px 8px; }.current-reading { display: flex; align-items: baseline; margin-top: 14px; }.current-reading strong { font: 800 43px/.9 var(--display); }.current-reading > span { color: var(--copper); font: 700 15px/1 var(--mono); margin-left: 6px; }.current-reading small { color: var(--muted); font-size: 9px; margin-left: 13px; } +.current-chart { width: 100%; height: 98px; margin-top: -7px; overflow: visible; }.current-chart .warn-line { stroke: var(--danger); stroke-width: 1; stroke-dasharray: 5 5; }.current-chart text { fill: var(--danger); font: 700 7px/1 var(--mono); }.current-chart #current-line { fill: none; stroke: var(--copper); stroke-width: 3; vector-effect: non-scaling-stroke; }.current-chart #current-area { fill: rgba(214,122,78,.14); } +.jam-state { display: flex; justify-content: space-between; color: var(--muted); font-size: 9px; }.jam-state i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--good); margin-right: 6px; }.jam-state b { color: var(--ink); font-family: var(--mono); } +.quality-row { display: grid; grid-template-columns: repeat(4,1fr); gap: 12px; margin-top: 15px; }.quality-row article { display: flex; gap: 11px; align-items: center; min-height: 76px; background: var(--ink); color: white; padding: 12px; }.quality-row small, .quality-row em { display: block; color: #b8b9c8; font: 700 8px/1.25 var(--mono); font-style: normal; }.quality-row b { display: block; font: 700 17px/1.45 var(--display); }.quality-row b i { font-style: normal; } +.quality-icon { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border: 2px solid var(--safety); color: var(--safety); font: 800 13px/1 var(--display); }.quality-icon--glue { border-color: var(--copper); color: var(--copper); }.quality-icon--weight { border-color: #aab6e0; color: #aab6e0; font-size: 9px; }.quality-icon--label { border-color: #75c59a; color: #75c59a; } + +.pallet-panel { padding: 24px 28px 20px; background: #ebe8df; } +.pallet-panel__top { display: flex; justify-content: space-between; align-items: center; }.pallet-panel__top small { color: var(--muted); font: 700 9px/1 var(--mono); letter-spacing: .14em; }.pallet-panel__top h2 { margin: 5px 0 0; font: 800 32px/1 var(--display); }.pallet-panel__top em { background: var(--ink); color: white; padding: 7px 10px; font: 700 9px/1 var(--mono); font-style: normal; } +.pallet-progress { display: grid; grid-template-columns: auto 1fr; column-gap: 9px; align-items: baseline; margin-top: 18px; }.pallet-progress strong { font: 800 58px/.9 var(--display); }.pallet-progress span { color: var(--muted); font-size: 11px; }.pallet-progress > i { grid-column: 1 / -1; height: 8px; background: #d6d1c7; margin-top: 10px; }.pallet-progress i b { display: block; width: 76%; height: 100%; background: var(--carton); } +.pallet-copy { display: grid; grid-template-columns: 1fr 1fr; margin-top: 17px; border-top: 1px solid #cdc8be; border-bottom: 1px solid #cdc8be; padding: 12px 0; }.pallet-copy span + span { border-left: 1px solid #cdc8be; padding-left: 18px; }.pallet-copy small { display: block; color: var(--muted); font-size: 9px; }.pallet-copy b { display: block; font: 700 17px/1.5 var(--mono); } +.pallet-grid { display: grid; grid-template-columns: repeat(6, 1fr); grid-auto-rows: 46px; gap: 6px; padding: 22px 10px 14px; border-bottom: 8px solid #6d5232; perspective: 400px; }.pallet-grid span { display: grid; place-items: center; border: 2px solid #b8b3a8; color: #8b887e; font: 700 9px/1 var(--mono); background: transparent; }.pallet-grid span.is-loaded { border-color: #9c6932; background: var(--carton); color: #3d2b19; box-shadow: inset 0 -5px 0 rgba(84,56,26,.14); }.pallet-grid span.is-next { border-color: var(--iris); color: var(--iris); background: #e8e9f5; animation: next-pick 1.5s steps(2,end) infinite; } +@keyframes next-pick { 50% { outline: 3px solid rgba(93,105,168,.28); } } +.pallet-key { display: flex; justify-content: center; gap: 18px; color: var(--muted); font-size: 9px; margin-top: 13px; }.pallet-key i { display: inline-block; width: 10px; height: 10px; border: 1px solid #aaa59b; vertical-align: -1px; margin-right: 5px; }.pallet-key i.loaded { background: var(--carton); border-color: #9c6932; }.pallet-key i.next { background: #e8e9f5; border-color: var(--iris); } +.pallet-foot { display: grid; grid-template-columns: 1fr 1fr; margin-top: 20px; }.pallet-foot span + span { border-left: 1px solid #cdc8be; padding-left: 18px; }.pallet-foot small { display: block; color: var(--muted); font-size: 9px; }.pallet-foot b { display: block; font: 700 14px/1.6 var(--mono); } + +.footer { display: grid; grid-template-columns: 1.35fr 1fr auto; align-items: center; min-height: 92px; border-top: 2px solid var(--ink); background: var(--panel); padding: 12px 38px; gap: 22px; }.material-watch { display: flex; align-items: center; gap: 13px; min-width: 0; }.material-watch__stripe { width: 16px; height: 52px; flex: 0 0 auto; background: repeating-linear-gradient(-45deg, var(--safety) 0 7px, var(--ink) 7px 14px); }.material-watch small { display: block; color: #987018; font: 700 9px/1 var(--mono); letter-spacing: .12em; }.material-watch b { display: block; margin-top: 5px; font: 650 13px/1.3 var(--body); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.material-watch em { margin-left: auto; color: var(--muted); font: 700 10px/1 var(--mono); font-style: normal; white-space: nowrap; } +.source-note { color: var(--muted); font: 600 8px/1.45 var(--mono); text-align: right; }.source-note b { color: var(--ink); }.source-note span { display: inline-block; width: 1px; height: 13px; background: var(--line); margin: 0 8px; vertical-align: -3px; } +button { appearance: none; border: 2px solid var(--ink); background: var(--paper); color: var(--ink); padding: 9px 12px; font: 700 10px/1 var(--body); cursor: pointer; }.footer button span { display: inline-grid; place-items: center; width: 22px; height: 22px; margin-right: 7px; background: var(--ink); color: white; font-family: var(--mono); }button:focus { outline: 4px solid var(--iris); outline-offset: 3px; } + +.board.is-jammed .status-band__label strong { color: var(--danger); }.board.is-jammed .status-band__label strong::before { background: var(--danger); }.board.is-jammed .status-band__rate { background: #e8d3d2; }.board.is-jammed .machine--alert { border: 3px solid var(--danger); background: #f7e7e5; }.board.is-jammed .machine--alert i { background: var(--danger); }.board.is-jammed .risk-chip { color: var(--danger) !important; border-color: var(--danger); }.board.is-jammed .jam-state i { background: var(--danger); } + +@media (max-width: 1450px) { .content { grid-template-columns: minmax(0,1fr) 360px; }.operations { padding-left: 24px; }.flow-track { grid-template-columns: 1fr 18px 1.1fr 18px 1fr 18px 1.05fr 18px 1fr; }.machine { padding-left: 39px; }.machine__index { left: 9px; }.status-band > div { padding-left: 25px; padding-right: 25px; } } +@media (max-width: 1050px) { body { overflow: auto; }.content { grid-template-columns: 1fr; }.operations { border-right: 0; }.pallet-panel { border-top: 2px solid var(--ink); }.flow-track { grid-template-columns: 1fr; gap: 7px; }.flow-arrow { transform: rotate(90deg); }.operation-grid, .quality-row { grid-template-columns: 1fr 1fr; }.footer { grid-template-columns: 1fr auto; }.source-note { display: none; } } +@media (prefers-reduced-motion: reduce) { .pallet-grid span.is-next { animation: none; } } + +/* --- OEE band (live packaging OEE, added for the OEE dashboard) --- */ +.oee-band { display: grid; grid-template-columns: .9fr 1fr 1fr 1fr; min-height: 118px; background: var(--ink); color: #fff; border-bottom: 2px solid var(--safety); } +.oee-band > div { padding: 16px 34px; display: flex; flex-direction: column; justify-content: center; } +.oee-band > div + div { border-left: 1px solid #3a3c58; } +.oee-band span { color: #b8b9c8; font: 700 10px/1 var(--mono); letter-spacing: .16em; } +.oee-band__hero { background: var(--safety); color: var(--ink); flex-direction: row !important; align-items: baseline; gap: 12px; } +.oee-band__hero span { color: #584415; align-self: center; } +.oee-band__hero strong { font: 800 66px/.85 var(--display); } +.oee-band__hero b { font: 700 20px/1 var(--display); color: #584415; } +.oee-band__part b { font: 800 40px/1 var(--display); margin-top: 9px; } +.oee-band__part b i { font-style: normal; } + +/* --- connection state pill --- */ +.source-state[data-state="warn"] { color: var(--safety); } +.source-state[data-state="warn"] i { background: var(--safety); box-shadow: 0 0 0 5px rgba(234,181,69,.15); } +.source-state[data-state="healthy"] { color: #74c398; } +.source-state[data-state="healthy"] i { background: #74c398; box-shadow: 0 0 0 5px rgba(116,195,152,.12); } + +/* --- value change flash --- */ +.value-updated { animation: flash 0.6s ease-out; } +@keyframes flash { 0% { color: var(--copper); } 100% { color: inherit; } } + +/* --- TV-at-distance legibility (55" suspended above a line): labels raised to a legible floor. + The process-flow cards are excluded from that bump — they are tiny (5 across), so their text is + sized to fit and the box made taller so the layer line stays inside. --- */ +.brand small, .header__meta small, .source-state, #source-detail, +.status-band span, .status-band small, .status-band__rate b, .status-band__rate small, +.section-heading small, .section-heading em, .throughput-main span, .plan-labels, +.throughput-stats small, .current-reading > span, .current-reading small, .jam-state, +.quality-row small, .quality-row em, .pallet-panel__top small, .pallet-progress span, +.pallet-copy small, .pallet-key, .pallet-foot small, .material-watch small, .material-watch em, +.source-note, .oee-band span, .oee-band__part span { font-size: 19px !important; letter-spacing: .04em; } +.throughput-stats b, .pallet-copy b, .pallet-foot b, .material-watch b { font-size: 27px !important; } +.section-heading h1, .section-heading h2 { font-size: 30px !important; } +.jam-state b { font-size: 22px !important; } +.status-band__sku b { font-size: 30px !important; } +/* process-flow cards: fit the small tiles, taller box keeps the layer line inside */ +.machine { min-height: 142px !important; } +.machine small, .machine em { font-size: 16px !important; line-height: 1.35 !important; } +.machine b { font-size: 22px !important; line-height: 1.35 !important; } +/* the .machine i bottom-bar rule is a descendant selector that also catches the nested + layer value in step E; keep that one inline text, not an absolute bar. */ +.machine div i { position: static !important; left: auto !important; right: auto !important; bottom: auto !important; height: auto !important; background: transparent !important; display: inline !important; font-style: normal !important; } + +/* --- TV fit + section-header + warn-label refinements (verified on the 55" panel) --- + 1) 02/03 headers float ABOVE their cards (the .op-col wrapper) like section 01; + 2) 02/03 are separated by a real column gutter and set apart from 01 by a top gap; the two + content cards are forced to equal height and their main measure shares one font size; + 3) the motor-current warn label is right-aligned (see index.html ); + 4) the whole board is trimmed to fit 1080p with a small overscan cushion — fonts untouched, + only paddings/margins/box heights reclaimed. + NOTE: this 2019 Tizen web runtime (Chromium ~63) ignores the `gap` shorthand on grid, so the + 02/03 gutter MUST use the grid-column-gap longhand — `gap` alone renders zero gutter. --- */ +.op-col { display: flex; flex-direction: column; min-width: 0; height: 100%; } +.op-col .section-heading { margin-bottom: 10px; } +.op-col .panel { margin-top: 0 !important; flex: 1 1 auto; } +.oee-band { min-height: 90px !important; } +.oee-band > div { padding-top: 8px !important; padding-bottom: 9px !important; } +.oee-band__hero strong { font-size: 58px !important; } +.status-band { min-height: 138px !important; } +.status-band > div { padding-top: 11px !important; padding-bottom: 11px !important; } +.status-band__rate strong { font-size: 52px !important; } +.status-band__label strong { font-size: 40px !important; } +.content { min-height: 0 !important; } +.operations { padding-top: 16px !important; padding-bottom: 8px !important; } +.machine { min-height: 104px !important; } +.operation-grid { margin-top: 22px !important; grid-column-gap: 28px !important; column-gap: 28px !important; } +.throughput-main strong, .current-reading strong { font-size: 48px !important; } +.panel { min-height: 0 !important; padding-top: 10px !important; padding-bottom: 10px !important; } +/* 03 (jam) card: flow the content down the full card height — the big reading aligns with 02's, + the dotted 9.0 A warn line sits at 02's good/reject bar, the chart is taller, and the jam-switch/ + palletizer row is pinned to the bottom. */ +.jam-panel { display: flex; flex-direction: column; } +.current-reading { margin-top: 20px; } +.current-chart { flex: 0 0 auto; height: 118px !important; margin-top: 6px !important; } +.current-chart text { font-size: 12px !important; } +.jam-state { margin-top: auto; } +.quality-row { margin-top: 9px !important; } +.quality-row article { min-height: 64px !important; } +.footer { min-height: 70px !important; } +.pallet-panel { padding-top: 16px !important; padding-bottom: 12px !important; } +.pallet-progress { margin-top: 10px !important; } +.pallet-copy { margin-top: 10px !important; padding: 9px 0 !important; } +.pallet-grid { grid-auto-rows: 40px !important; padding: 12px 10px 10px !important; } +.pallet-key { margin-top: 8px !important; } +.pallet-foot { margin-top: 10px !important; }