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

363
main.go
View file

@ -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, &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
}
@ -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 = `<!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);
}
}