Added PLC status and force data, plc data

This commit is contained in:
Gamer 2026-04-16 19:27:50 +02:00
parent e3ac53da44
commit 7614390b74

303
main.go
View file

@ -50,6 +50,8 @@ type ThresholdsConfig struct {
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,
@ -103,11 +107,17 @@ func defaultConfig() Config {
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
@ -326,6 +346,8 @@ type PageData struct {
WarningPercent float64
CriticalPercent float64
GaugeMaxPercent float64
ImbalanceWarningPercent float64
ImbalanceCriticalPercent float64
PollMs int
DefaultWindow string
}
@ -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, &notNull, &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
}
@ -736,6 +801,8 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
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),
}
@ -835,7 +902,7 @@ const uiHTML = `<!DOCTYPE html>
position: relative;
width: 100%;
max-width: 500px;
height: 390px;
height: 360px;
margin: 0 auto;
}
@ -856,6 +923,69 @@ const uiHTML = `<!DOCTYPE html>
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; }
</style>
</head>
<body>
@ -863,7 +993,7 @@ const uiHTML = `<!DOCTYPE html>
<div id="alarm-banner" class="hidden mb-6 bg-red-600/90 border border-red-500 text-white px-8 py-4 rounded-2xl flex items-center justify-between text-lg font-medium">
<div class="flex items-center gap-3">
<span class="text-2xl"></span>
<span id="alarm-text">CRITICAL FORCE ALARM ACTIVE</span>
<span id="alarm-text">CRITICAL ALARM ACTIVE</span>
</div>
</div>
@ -886,6 +1016,41 @@ const uiHTML = `<!DOCTYPE html>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-8">
<div id="summary-force-card" class="summary-card neutral">
<div class="flex items-center gap-3">
<div id="summary-force-dot" class="summary-dot neutral"></div>
<div>
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">FORCE</div>
<div id="summary-force-text" class="summary-status neutral font-semibold mt-1">NO DATA</div>
</div>
</div>
<div id="summary-force-value" class="font-mono text-zinc-200 text-lg">--</div>
</div>
<div id="summary-imbalance-card" class="summary-card neutral">
<div class="flex items-center gap-3">
<div id="summary-imbalance-dot" class="summary-dot neutral"></div>
<div>
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">IMBALANCE</div>
<div id="summary-imbalance-text" class="summary-status neutral font-semibold mt-1">NO DATA</div>
</div>
</div>
<div id="summary-imbalance-value" class="font-mono text-zinc-200 text-lg">--</div>
</div>
<div id="summary-plc-card" class="summary-card neutral">
<div class="flex items-center gap-3">
<div id="summary-plc-dot" class="summary-dot neutral"></div>
<div>
<div class="text-xs uppercase tracking-[0.22em] text-zinc-500">PLC</div>
<div id="summary-plc-text" class="summary-status neutral font-semibold mt-1">OFFLINE</div>
</div>
</div>
<div id="summary-plc-value" class="font-mono text-zinc-200 text-lg">Disconnected</div>
</div>
</div>
<div class="glass border border-white/10 rounded-3xl p-6 md:p-8 mb-8">
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
<div>
@ -915,9 +1080,9 @@ const uiHTML = `<!DOCTYPE html>
</div>
<div class="bg-zinc-900/60 rounded-2xl px-5 py-4 border border-zinc-800">
<div class="text-zinc-500 text-xs uppercase tracking-widest">DB</div>
<div class="text-sm font-mono text-zinc-300 mt-2">SQLite WAL</div>
<div class="text-xs text-zinc-500 mt-2 font-mono">non-blocking writer</div>
<div class="text-zinc-500 text-xs uppercase tracking-widest">LIMITS</div>
<div class="text-sm font-mono text-zinc-300 mt-2">Force W {{printf "%.0f" .WarningPercent}} / C {{printf "%.0f" .CriticalPercent}}</div>
<div class="text-xs text-zinc-500 mt-2 font-mono">Imb W {{printf "%.0f" .ImbalanceWarningPercent}} / C {{printf "%.0f" .ImbalanceCriticalPercent}}</div>
</div>
</div>
</div>
@ -1003,6 +1168,8 @@ const uiHTML = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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);
}
}