From 7614390b7486f0b9f42c0c25efec312bf20e1963 Mon Sep 17 00:00:00 2001 From: Gamer Date: Thu, 16 Apr 2026 19:27:50 +0200 Subject: [PATCH] Added PLC status and force data, plc data --- main.go | 363 +++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 297 insertions(+), 66 deletions(-) diff --git a/main.go b/main.go index b8dee58..279a707 100644 --- a/main.go +++ b/main.go @@ -47,9 +47,11 @@ type PLCConfig struct { } type ThresholdsConfig struct { - WarningPercent float64 `yaml:"warning_percent"` - CriticalPercent float64 `yaml:"critical_percent"` - GaugeMaxPercent float64 `yaml:"gauge_max_percent"` + WarningPercent float64 `yaml:"warning_percent"` + CriticalPercent float64 `yaml:"critical_percent"` + GaugeMaxPercent float64 `yaml:"gauge_max_percent"` + ImbalanceWarningPercent float64 `yaml:"imbalance_warning_percent"` + ImbalanceCriticalPercent float64 `yaml:"imbalance_critical_percent"` LegacyWarningKn float64 `yaml:"warning_kn,omitempty"` LegacyCriticalKn float64 `yaml:"critical_kn,omitempty"` @@ -88,7 +90,9 @@ type DBConfig struct { func defaultConfig() Config { return Config{ - Server: ServerConfig{ListenAddr: ":8080"}, + Server: ServerConfig{ + ListenAddr: ":8080", + }, PLC: PLCConfig{ IP: "192.168.0.1", DBNum: 1001, @@ -100,14 +104,20 @@ func defaultConfig() Config { ReconnectDelaySec: 5, }, Thresholds: ThresholdsConfig{ - WarningPercent: 80, - CriticalPercent: 95, - GaugeMaxPercent: 130, + WarningPercent: 80, + CriticalPercent: 95, + GaugeMaxPercent: 130, + ImbalanceWarningPercent: 10, + ImbalanceCriticalPercent: 20, + }, + Trend: TrendConfig{ + Minutes: 5, + }, + Press: PressConfig{ + MAX_TONNAGE: 64, }, - Trend: TrendConfig{Minutes: 5}, - Press: PressConfig{MAX_TONNAGE: 64}, UI: UIConfig{ - Title: "Force Monitor v0.2.0 - pre-alpha", + Title: "Force Monitor", Subtitle: "Siemens S7-1215C • Piezo peak/stroke input • PLC values in % • kN calculated from MAX_TONNAGE", LeftLabel: "LEVI STEBER", RightLabel: "DESNI STEBER", @@ -163,6 +173,7 @@ func normalizeConfig(cfg *Config) { if cfg.Thresholds.GaugeMaxPercent <= 0 && cfg.Thresholds.LegacyMaxKn > 0 { cfg.Thresholds.GaugeMaxPercent = cfg.Thresholds.LegacyMaxKn } + if cfg.Thresholds.WarningPercent <= 0 { cfg.Thresholds.WarningPercent = def.Thresholds.WarningPercent } @@ -172,6 +183,15 @@ func normalizeConfig(cfg *Config) { if cfg.Thresholds.GaugeMaxPercent <= 0 { cfg.Thresholds.GaugeMaxPercent = def.Thresholds.GaugeMaxPercent } + if cfg.Thresholds.ImbalanceWarningPercent <= 0 { + cfg.Thresholds.ImbalanceWarningPercent = def.Thresholds.ImbalanceWarningPercent + } + if cfg.Thresholds.ImbalanceCriticalPercent <= 0 { + cfg.Thresholds.ImbalanceCriticalPercent = def.Thresholds.ImbalanceCriticalPercent + } + if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent { + cfg.Thresholds.ImbalanceCriticalPercent = cfg.Thresholds.ImbalanceWarningPercent + } if cfg.Trend.Minutes <= 0 { cfg.Trend.Minutes = def.Trend.Minutes @@ -316,18 +336,20 @@ type HistoryResponse struct { } type PageData struct { - Title string - Subtitle string - LeftLabel string - RightLabel string - UnitForce string - UnitPct string - MaxTonnage float64 - WarningPercent float64 - CriticalPercent float64 - GaugeMaxPercent float64 - PollMs int - DefaultWindow string + Title string + Subtitle string + LeftLabel string + RightLabel string + UnitForce string + UnitPct string + MaxTonnage float64 + WarningPercent float64 + CriticalPercent float64 + GaugeMaxPercent float64 + ImbalanceWarningPercent float64 + ImbalanceCriticalPercent float64 + PollMs int + DefaultWindow string } var ( @@ -390,6 +412,41 @@ func enqueueSample(s Sample) { } } +func ensureColumn(database *sql.DB, tableName, columnName, definition string) error { + rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName)) + if err != nil { + return err + } + defer rows.Close() + + found := false + for rows.Next() { + var cid int + var name string + var ctype string + var notNull int + var dfltValue sql.NullString + var pk int + + if err := rows.Scan(&cid, &name, &ctype, ¬Null, &dfltValue, &pk); err != nil { + return err + } + if name == columnName { + found = true + break + } + } + if err := rows.Err(); err != nil { + return err + } + if found { + return nil + } + + _, err = database.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", tableName, columnName, definition)) + return err +} + func initDatabase(dbPath string) (*sql.DB, error) { dsn := fmt.Sprintf("file:%s?_busy_timeout=%d&_foreign_keys=on", filepath.ToSlash(dbPath), cfg.DB.BusyTimeoutMs) database, err := sql.Open("sqlite3", dsn) @@ -426,8 +483,8 @@ CREATE TABLE IF NOT EXISTS samples ( sila_r_kn REAL NOT NULL, sum_pct REAL NOT NULL, sum_kn REAL NOT NULL, - imbalance_pct REAL NOT NULL, - bias_pct REAL NOT NULL + imbalance_pct REAL NOT NULL DEFAULT 0, + bias_pct REAL NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts); ` @@ -436,6 +493,15 @@ CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts); return nil, fmt.Errorf("create schema: %w", err) } + if err := ensureColumn(database, "samples", "imbalance_pct", "REAL NOT NULL DEFAULT 0"); err != nil { + _ = database.Close() + return nil, fmt.Errorf("ensure imbalance_pct column: %w", err) + } + if err := ensureColumn(database, "samples", "bias_pct", "REAL NOT NULL DEFAULT 0"); err != nil { + _ = database.Close() + return nil, fmt.Errorf("ensure bias_pct column: %w", err) + } + return database, nil } @@ -664,7 +730,6 @@ func queryHistory(window time.Duration) ([]HistoryPoint, error) { if len(points) <= cfg.DB.MaxChartPoints { return points, nil } - return downsamplePoints(points, cfg.DB.MaxChartPoints), nil } @@ -726,18 +791,20 @@ func apiHistory(w http.ResponseWriter, r *http.Request) { func serveUI(w http.ResponseWriter, r *http.Request) { data := PageData{ - Title: cfg.UI.Title, - Subtitle: cfg.UI.Subtitle, - LeftLabel: cfg.UI.LeftLabel, - RightLabel: cfg.UI.RightLabel, - UnitForce: cfg.UI.UnitForce, - UnitPct: cfg.UI.UnitPct, - MaxTonnage: cfg.Press.MAX_TONNAGE, - WarningPercent: cfg.Thresholds.WarningPercent, - CriticalPercent: cfg.Thresholds.CriticalPercent, - GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent, - PollMs: cfg.PLC.PollMs, - DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes), + Title: cfg.UI.Title, + Subtitle: cfg.UI.Subtitle, + LeftLabel: cfg.UI.LeftLabel, + RightLabel: cfg.UI.RightLabel, + UnitForce: cfg.UI.UnitForce, + UnitPct: cfg.UI.UnitPct, + MaxTonnage: cfg.Press.MAX_TONNAGE, + WarningPercent: cfg.Thresholds.WarningPercent, + CriticalPercent: cfg.Thresholds.CriticalPercent, + GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent, + ImbalanceWarningPercent: cfg.Thresholds.ImbalanceWarningPercent, + ImbalanceCriticalPercent: cfg.Thresholds.ImbalanceCriticalPercent, + PollMs: cfg.PLC.PollMs, + DefaultWindow: fmt.Sprintf("%dm", cfg.Trend.Minutes), } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -835,7 +902,7 @@ const uiHTML = ` position: relative; width: 100%; max-width: 500px; - height: 390px; + height: 360px; margin: 0 auto; } @@ -856,6 +923,69 @@ const uiHTML = ` width: min(92vw, 1800px); margin: 0 auto; } + + .summary-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-radius: 20px; + padding: 18px 20px; + border: 1px solid rgba(255,255,255,0.09); + background: rgba(255,255,255,0.04); + transition: 180ms ease; + } + + .summary-card.ok { + border-color: rgba(34,197,94,0.35); + box-shadow: 0 0 0 1px rgba(34,197,94,0.08) inset, 0 0 26px rgba(34,197,94,0.06); + } + + .summary-card.warning { + border-color: rgba(234,179,8,0.35); + box-shadow: 0 0 0 1px rgba(234,179,8,0.08) inset, 0 0 26px rgba(234,179,8,0.06); + } + + .summary-card.critical { + border-color: rgba(239,68,68,0.35); + box-shadow: 0 0 0 1px rgba(239,68,68,0.08) inset, 0 0 26px rgba(239,68,68,0.06); + } + + .summary-card.neutral { + border-color: rgba(113,113,122,0.35); + box-shadow: 0 0 0 1px rgba(113,113,122,0.08) inset; + } + + .summary-dot { + width: 14px; + height: 14px; + border-radius: 999px; + } + + .summary-dot.ok { + background: #10b981; + box-shadow: 0 0 14px rgba(16,185,129,0.55); + } + + .summary-dot.warning { + background: #f59e0b; + box-shadow: 0 0 14px rgba(245,158,11,0.55); + } + + .summary-dot.critical { + background: #ef4444; + box-shadow: 0 0 14px rgba(239,68,68,0.55); + } + + .summary-dot.neutral { + background: #71717a; + box-shadow: 0 0 12px rgba(113,113,122,0.35); + } + + .summary-status.ok { color: #34d399; } + .summary-status.warning { color: #facc15; } + .summary-status.critical { color: #f87171; } + .summary-status.neutral { color: #a1a1aa; } @@ -863,7 +993,7 @@ const uiHTML = ` @@ -886,6 +1016,41 @@ const uiHTML = ` +
+
+
+
+
+
FORCE
+
NO DATA
+
+
+
--
+
+ +
+
+
+
+
IMBALANCE
+
NO DATA
+
+
+
--
+
+ +
+
+
+
+
PLC
+
OFFLINE
+
+
+
Disconnected
+
+
+
@@ -915,9 +1080,9 @@ const uiHTML = `
-
DB
-
SQLite WAL
-
non-blocking writer
+
LIMITS
+
Force W {{printf "%.0f" .WarningPercent}} / C {{printf "%.0f" .CriticalPercent}}
+
Imb W {{printf "%.0f" .ImbalanceWarningPercent}} / C {{printf "%.0f" .ImbalanceCriticalPercent}}
@@ -1003,6 +1168,8 @@ const uiHTML = ` const WARNING_PERCENT = {{.WarningPercent}}; const CRITICAL_PERCENT = {{.CriticalPercent}}; const GAUGE_MAX_PERCENT = {{.GaugeMaxPercent}}; + const IMBALANCE_WARNING_PERCENT = {{.ImbalanceWarningPercent}}; + const IMBALANCE_CRITICAL_PERCENT = {{.ImbalanceCriticalPercent}}; const UNIT_FORCE = '{{.UnitForce}}'; const UNIT_PCT = '{{.UnitPct}}'; const POLL_MS = {{.PollMs}}; @@ -1107,15 +1274,15 @@ const uiHTML = ` const { ctx, w, h } = prepCanvas(canvas); const cx = w / 2; - const cy = h * 0.55; - const radius = Math.min(w, h) * 0.37; + const cy = h * 0.57; + const radius = Math.min(w, h) * 0.34; const trackWidth = Math.max(18, radius * 0.16); const value = clamp(Number(percentValue) || 0, 0, GAUGE_MAX_PERCENT); const valueAngle = valueToAngle(value); ctx.save(); ctx.beginPath(); - ctx.arc(cx, cy, radius + 24, 0, Math.PI * 2); + ctx.arc(cx, cy, radius + 22, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.015)'; ctx.shadowColor = 'rgba(0,0,0,0.45)'; ctx.shadowBlur = 30; @@ -1199,34 +1366,47 @@ const uiHTML = ` ctx.fillStyle = '#ffffff'; ctx.fill(); + const majorTickInner = radius * 0.72; + const centerPlateRadius = majorTickInner - 18; ctx.beginPath(); - ctx.arc(cx, cy + 8, radius * 0.36, 0, Math.PI * 2); - ctx.fillStyle = 'rgba(9,9,11,0.85)'; + ctx.arc(cx, cy + 8, centerPlateRadius, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(9,9,11,0.90)'; ctx.fill(); - ctx.lineWidth = 1; + ctx.lineWidth = 1.2; ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.stroke(); + const valueText = value.toFixed(1); + let valueFontPx = 52; + if (value >= 100) valueFontPx = 46; + if (w < 420) valueFontPx -= 4; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#ffffff'; - ctx.font = '700 48px Space Grotesk, Inter, sans-serif'; - ctx.fillText(value.toFixed(1), cx, cy - 8); + ctx.font = '700 ' + valueFontPx + 'px Space Grotesk, Inter, sans-serif'; + ctx.fillText(valueText, cx, cy - 6); ctx.fillStyle = sideAccent; ctx.font = '700 18px Inter, sans-serif'; - ctx.fillText(UNIT_PCT, cx, cy + 26); + ctx.fillText(UNIT_PCT, cx, cy + 28); ctx.fillStyle = '#a1a1aa'; ctx.font = '600 16px Inter, sans-serif'; - ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 52); + ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 54); } function getZone(percentValue) { if (percentValue >= CRITICAL_PERCENT) return 'critical'; if (percentValue >= WARNING_PERCENT) return 'warning'; - return 'normal'; + return 'ok'; + } + + function getImbalanceZone(value) { + if (value >= IMBALANCE_CRITICAL_PERCENT) return 'critical'; + if (value >= IMBALANCE_WARNING_PERCENT) return 'warning'; + return 'ok'; } function setStatusConnected(connected) { @@ -1283,25 +1463,73 @@ const uiHTML = ` return 'Last update: ' + hh + ':' + mm + ':' + ss + '.' + ms; } - function updateAlarmBanner(leftPercent, rightPercent) { + function setSummaryCard(kind, zone, text, value) { + const card = document.getElementById('summary-' + kind + '-card'); + const dot = document.getElementById('summary-' + kind + '-dot'); + const status = document.getElementById('summary-' + kind + '-text'); + const val = document.getElementById('summary-' + kind + '-value'); + + card.className = 'summary-card ' + zone; + dot.className = 'summary-dot ' + zone; + status.className = 'summary-status ' + zone + ' font-semibold mt-1'; + status.textContent = text; + val.textContent = value; + } + + function updateSummaryBar(connected, leftPercent, rightPercent, imbalance) { + if (!connected) { + setSummaryCard('force', 'neutral', 'NO DATA', '--'); + setSummaryCard('imbalance', 'neutral', 'NO DATA', '--'); + setSummaryCard('plc', 'critical', 'OFFLINE', 'Disconnected'); + return; + } + + const maxForce = Math.max(leftPercent, rightPercent); + const forceZone = getZone(maxForce); + const dominantSide = leftPercent >= rightPercent ? 'L' : 'R'; + const forceText = forceZone === 'ok' ? 'OK' : forceZone === 'warning' ? 'WARNING' : 'CRITICAL'; + setSummaryCard('force', forceZone, forceText, 'Max ' + maxForce.toFixed(1) + UNIT_PCT + ' (' + dominantSide + ')'); + + const imbalanceZone = getImbalanceZone(imbalance); + const imbalanceText = imbalanceZone === 'ok' ? 'OK' : imbalanceZone === 'warning' ? 'WARNING' : 'CRITICAL'; + setSummaryCard('imbalance', imbalanceZone, imbalanceText, imbalance.toFixed(1) + UNIT_PCT); + + setSummaryCard('plc', 'ok', 'OK', 'Online'); + } + + function updateAlarmBanner(leftPercent, rightPercent, imbalancePercent, connected) { const banner = document.getElementById('alarm-banner'); const text = document.getElementById('alarm-text'); + if (!connected) { + banner.classList.add('hidden'); + return; + } + const leftCritical = leftPercent >= CRITICAL_PERCENT; const rightCritical = rightPercent >= CRITICAL_PERCENT; + const imbalanceCritical = imbalancePercent >= IMBALANCE_CRITICAL_PERCENT; - if (leftCritical || rightCritical) { - if (leftCritical && rightCritical) { - text.textContent = 'CRITICAL FORCE ALARM ACTIVE • LEFT + RIGHT'; - } else if (leftCritical) { - text.textContent = 'CRITICAL FORCE ALARM ACTIVE • LEFT'; - } else { - text.textContent = 'CRITICAL FORCE ALARM ACTIVE • RIGHT'; - } - banner.classList.remove('hidden'); - } else { + if (!leftCritical && !rightCritical && !imbalanceCritical) { banner.classList.add('hidden'); + return; } + + const parts = []; + if (leftCritical && rightCritical) { + parts.push('FORCE LEFT + RIGHT'); + } else if (leftCritical) { + parts.push('FORCE LEFT'); + } else if (rightCritical) { + parts.push('FORCE RIGHT'); + } + + if (imbalanceCritical) { + parts.push('IMBALANCE'); + } + + text.textContent = 'CRITICAL ALARM ACTIVE • ' + parts.join(' • '); + banner.classList.remove('hidden'); } function redrawGauges() { @@ -1322,6 +1550,7 @@ const uiHTML = ` const d = await res.json(); latestData = d; + const connected = !!d.connected; const leftPercent = Number(d.sila_l) || 0; const rightPercent = Number(d.sila_r) || 0; const leftKN = Number(d.sila_l_kn) || 0; @@ -1331,7 +1560,7 @@ const uiHTML = ` const imbalance = Number(d.imbalance_percent) || 0; const bias = Number(d.bias_percent) || 0; - setStatusConnected(!!d.connected); + setStatusConnected(connected); document.querySelector('#digital-l .percent').textContent = leftPercent.toFixed(1); document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ' + UNIT_FORCE; @@ -1348,11 +1577,13 @@ const uiHTML = ` applyChannelState('l', leftPercent); applyChannelState('r', rightPercent); - updateAlarmBanner(leftPercent, rightPercent); + updateSummaryBar(connected, leftPercent, rightPercent, imbalance); + updateAlarmBanner(leftPercent, rightPercent, imbalance, connected); redrawGauges(); } catch (err) { console.warn('Live fetch error:', err); setStatusConnected(false); + updateSummaryBar(false, 0, 0, 0); } }