diff --git a/main.go b/main.go index 135336d..396f732 100644 --- a/main.go +++ b/main.go @@ -7,10 +7,10 @@ import ( "fmt" "html/template" "log" + "math" "net/http" "os" "path/filepath" - "sort" "strconv" "strings" "sync" @@ -108,7 +108,7 @@ func defaultConfig() Config { Press: PressConfig{MAX_TONNAGE: 64}, UI: UIConfig{ Title: "Force Monitor", - Subtitle: "Siemens S7-1215C • Live monitoring • PLC values in % • kN calculated from MAX_TONNAGE", + Subtitle: "Siemens S7-1215C • Piezo peak/stroke input • PLC values in % • kN calculated from MAX_TONNAGE", LeftLabel: "LEVI STEBER", RightLabel: "DESNI STEBER", UnitForce: "kN", @@ -263,45 +263,45 @@ func loadOrCreateConfig(configPath string) (Config, error) { return cfg, nil } -type Measurement struct { - Time string `json:"time"` - SilaL float32 `json:"sila_l"` - SilaR float32 `json:"sila_r"` -} - type Sample struct { - TS time.Time - SilaLPct float32 - SilaRPct float32 - SilaLKN float32 - SilaRKN float32 - SumPercent float32 - SumKN float32 + TS time.Time + SilaLPct float32 + SilaRPct float32 + SilaLKN float32 + SilaRKN float32 + SumPercent float32 + SumKN float32 + ImbalancePercent float32 + BiasPercent float32 } type AppState struct { sync.RWMutex - Connected bool - SilaL float32 - SilaR float32 - SilaLkN float32 - SilaRkN float32 - SumPercent float32 - SumkN float32 - LastUpdate time.Time - DroppedSamples uint64 + Connected bool + SilaL float32 + SilaR float32 + SilaLkN float32 + SilaRkN float32 + SumPercent float32 + SumkN float32 + ImbalancePercent float32 + BiasPercent float32 + LastUpdate time.Time + DroppedSamples uint64 } type APIState struct { - Connected bool `json:"connected"` - SilaL float32 `json:"sila_l"` - SilaR float32 `json:"sila_r"` - SilaLkN float32 `json:"sila_l_kn"` - SilaRkN float32 `json:"sila_r_kn"` - SumPercent float32 `json:"sum_percent"` - SumkN float32 `json:"sum_kn"` - LastUpdate string `json:"last_update"` - DroppedSamples uint64 `json:"dropped_samples"` + Connected bool `json:"connected"` + SilaL float32 `json:"sila_l"` + SilaR float32 `json:"sila_r"` + SilaLkN float32 `json:"sila_l_kn"` + SilaRkN float32 `json:"sila_r_kn"` + SumPercent float32 `json:"sum_percent"` + SumkN float32 `json:"sum_kn"` + ImbalancePercent float32 `json:"imbalance_percent"` + BiasPercent float32 `json:"bias_percent"` + LastUpdate string `json:"last_update"` + DroppedSamples uint64 `json:"dropped_samples"` } type HistoryPoint struct { @@ -360,15 +360,17 @@ func snapshotState() APIState { } return APIState{ - Connected: state.Connected, - SilaL: state.SilaL, - SilaR: state.SilaR, - SilaLkN: state.SilaLkN, - SilaRkN: state.SilaRkN, - SumPercent: state.SumPercent, - SumkN: state.SumkN, - LastUpdate: lastUpdate, - DroppedSamples: state.DroppedSamples, + Connected: state.Connected, + SilaL: state.SilaL, + SilaR: state.SilaR, + SilaLkN: state.SilaLkN, + SilaRkN: state.SilaRkN, + SumPercent: state.SumPercent, + SumkN: state.SumkN, + ImbalancePercent: state.ImbalancePercent, + BiasPercent: state.BiasPercent, + LastUpdate: lastUpdate, + DroppedSamples: state.DroppedSamples, } } @@ -423,11 +425,12 @@ CREATE TABLE IF NOT EXISTS samples ( sila_l_kn REAL NOT NULL, sila_r_kn REAL NOT NULL, sum_pct REAL NOT NULL, - sum_kn REAL NOT NULL + sum_kn REAL NOT NULL, + imbalance_pct REAL NOT NULL, + bias_pct REAL NOT NULL ); CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts); ` - if _, err := database.Exec(schema); err != nil { _ = database.Close() return nil, fmt.Errorf("create schema: %w", err) @@ -453,7 +456,12 @@ func startDBWriter(database *sql.DB) { return } - stmt, err := tx.Prepare(`INSERT INTO samples (ts, sila_l_pct, sila_r_pct, sila_l_kn, sila_r_kn, sum_pct, sum_kn) VALUES (?, ?, ?, ?, ?, ?, ?)`) + stmt, err := tx.Prepare(` + INSERT INTO samples ( + ts, sila_l_pct, sila_r_pct, sila_l_kn, sila_r_kn, + sum_pct, sum_kn, imbalance_pct, bias_pct + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) if err != nil { _ = tx.Rollback() log.Printf("db prepare failed: %v", err) @@ -470,6 +478,8 @@ func startDBWriter(database *sql.DB) { s.SilaRKN, s.SumPercent, s.SumKN, + s.ImbalancePercent, + s.BiasPercent, ) if err != nil { ok = false @@ -557,6 +567,8 @@ func startPLCPoller() { silaR := helper.GetRealAt(buf, 4) leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR) + imbalance := float32(math.Abs(float64(silaL - silaR))) + bias := silaL - silaR now := time.Now() state.Lock() @@ -567,17 +579,21 @@ func startPLCPoller() { state.SilaRkN = rightKN state.SumPercent = sumPercent state.SumkN = sumKN + state.ImbalancePercent = imbalance + state.BiasPercent = bias state.LastUpdate = now state.Unlock() enqueueSample(Sample{ - TS: now, - SilaLPct: silaL, - SilaRPct: silaR, - SilaLKN: leftKN, - SilaRKN: rightKN, - SumPercent: sumPercent, - SumKN: sumKN, + TS: now, + SilaLPct: silaL, + SilaRPct: silaR, + SilaLKN: leftKN, + SilaRKN: rightKN, + SumPercent: sumPercent, + SumKN: sumKN, + ImbalancePercent: imbalance, + BiasPercent: bias, }) time.Sleep(time.Duration(cfg.PLC.PollMs) * time.Millisecond) @@ -607,6 +623,14 @@ func parseWindow(raw string) (time.Duration, string, error) { return d, s, nil } +func formatHistoryLabel(t time.Time, window time.Duration) string { + local := t.Local() + if window >= 12*time.Hour { + return local.Format("02.01 15:04") + } + return local.Format("15:04:05.000") +} + func queryHistory(window time.Duration) ([]HistoryPoint, error) { cutoff := time.Now().Add(-window).UTC().Format(time.RFC3339Nano) @@ -628,7 +652,7 @@ func queryHistory(window time.Duration) ([]HistoryPoint, error) { continue } points = append(points, HistoryPoint{ - Time: t.Local().Format("15:04:05.000"), + Time: formatHistoryLabel(t, window), SilaL: float32(l), SilaR: float32(r), }) @@ -641,15 +665,14 @@ func queryHistory(window time.Duration) ([]HistoryPoint, error) { return points, nil } - sampled := downsamplePoints(points, cfg.DB.MaxChartPoints) - sort.Slice(sampled, func(i, j int) bool { return sampled[i].Time < sampled[j].Time }) - return sampled, nil + return downsamplePoints(points, cfg.DB.MaxChartPoints), nil } func downsamplePoints(points []HistoryPoint, max int) []HistoryPoint { if len(points) <= max || max < 3 { return points } + out := make([]HistoryPoint, 0, max) step := float64(len(points)-1) / float64(max-1) used := make(map[int]struct{}, max) @@ -666,9 +689,10 @@ func downsamplePoints(points []HistoryPoint, max int) []HistoryPoint { out = append(out, points[idx]) } - if out[len(out)-1].Time != points[len(points)-1].Time { - out[len(out)-1] = points[len(points)-1] + if len(out) == 0 { + return points } + out[len(out)-1] = points[len(points)-1] return out } @@ -782,11 +806,10 @@ const uiHTML = ` --bg1: #050816; --bg2: #0b1224; --panel: rgba(255,255,255,0.055); - --line: rgba(255,255,255,0.09); - --muted: #a1a1aa; } * { box-sizing: border-box; } + body { font-family: 'Inter', system-ui, sans-serif; background: @@ -795,33 +818,40 @@ const uiHTML = ` linear-gradient(180deg, var(--bg1) 0%, var(--bg2) 100%); color: #f4f4f5; } + .title { font-family: 'Space Grotesk', sans-serif; } + .glass { background: var(--panel); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); } + .soft-glow-green { box-shadow: 0 0 0 1px rgba(34,197,94,0.28), 0 0 38px rgba(34,197,94,0.08); } .soft-glow-yellow { box-shadow: 0 0 0 1px rgba(234,179,8,0.28), 0 0 38px rgba(234,179,8,0.08); } .soft-glow-red { box-shadow: 0 0 0 1px rgba(239,68,68,0.28), 0 0 38px rgba(239,68,68,0.08); } + .gauge-container { position: relative; width: 100%; - max-width: 420px; - height: 340px; + max-width: 500px; + height: 390px; margin: 0 auto; } + .gauge-canvas { width: 100%; height: 100%; display: block; } + .window-btn.active { border-color: rgba(34,211,238,0.9); color: white; background: rgba(34,211,238,0.14); box-shadow: 0 0 0 1px rgba(34,211,238,0.18) inset; } + .chart-wrap { width: min(92vw, 1800px); margin: 0 auto; @@ -843,6 +873,7 @@ const uiHTML = `

{{.Subtitle}}

MAX_TONNAGE = {{printf "%.1f" .MaxTonnage}} {{.UnitForce}}

+
@@ -858,24 +889,35 @@ const uiHTML = `
-
TOTAL FORCE
+
TOTAL PEAK FORCE
0.0
{{.UnitForce}}
-
+ +
TOTAL %
0.0 {{.UnitPct}}
+
-
LIMITS
-
W {{printf "%.0f" .WarningPercent}}{{.UnitPct}} • C {{printf "%.0f" .CriticalPercent}}{{.UnitPct}}
+
IMBALANCE
+
0.0 {{.UnitPct}}
+
abs(L - R)
+ +
+
BIAS
+
0.0 {{.UnitPct}}
+
L - R
+
+
DB
-
SQLite WAL • non-blocking writer
+
SQLite WAL
+
non-blocking writer
@@ -887,16 +929,18 @@ const uiHTML = `
-

{{.LeftLabel}}

+

{{.LeftLabel}}

NORMAL
+
-
0.0
+
0.0
{{.UnitPct}}
0.0 {{.UnitForce}}
+
@@ -907,16 +951,18 @@ const uiHTML = `
-

{{.RightLabel}}

+

{{.RightLabel}}

NORMAL
+
-
0.0
+
0.0
{{.UnitPct}}
0.0 {{.UnitForce}}
+
@@ -927,9 +973,10 @@ const uiHTML = `
-

Live Trend

-
Chart reads selected history range directly from SQLite
+

Peak Trend

+
Piezo peak/stroke history from SQLite
+
@@ -944,6 +991,7 @@ const uiHTML = `
+
@@ -1052,40 +1100,42 @@ const uiHTML = ` } } - function drawGauge(canvasId, percentValue, knValue, zone, sideAccent) { + function drawGauge(canvasId, percentValue, knValue, sideAccent) { const canvas = document.getElementById(canvasId); if (!canvas) return; + const { ctx, w, h } = prepCanvas(canvas); const cx = w / 2; - const cy = h * 0.64; - const radius = Math.min(w, h) * 0.35; - const trackWidth = Math.max(16, radius * 0.16); + const cy = h * 0.55; + const radius = Math.min(w, h) * 0.37; + 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 + 20, 0, Math.PI * 2); + ctx.arc(cx, cy, radius + 24, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.015)'; ctx.shadowColor = 'rgba(0,0,0,0.45)'; - ctx.shadowBlur = 28; + ctx.shadowBlur = 30; ctx.fill(); ctx.restore(); drawArc(ctx, cx, cy, radius, START_ANGLE, END_ANGLE, 'rgba(255,255,255,0.06)', trackWidth + 10, 0); drawColoredBand(ctx, cx, cy, radius, trackWidth); - drawArc(ctx, cx, cy, radius, valueAngle, END_ANGLE, 'rgba(9,9,11,0.58)', trackWidth - 1, 0); - drawArc(ctx, cx, cy, radius, START_ANGLE, valueAngle, 'rgba(255,255,255,0.04)', trackWidth - 1, 8); + drawArc(ctx, cx, cy, radius, valueAngle, END_ANGLE, 'rgba(9,9,11,0.60)', trackWidth - 1, 0); + drawArc(ctx, cx, cy, radius, START_ANGLE, valueAngle, 'rgba(255,255,255,0.04)', trackWidth - 1, 10); for (let v = 0; v <= GAUGE_MAX_PERCENT + 0.0001; v += 5) { const a = valueToAngle(v); const isMajor = Math.abs(v % 10) < 0.0001; const isThreshold = Math.abs(v - WARNING_PERCENT) < 0.0001 || Math.abs(v - CRITICAL_PERCENT) < 0.0001; - const r1 = isThreshold ? radius * 0.67 : isMajor ? radius * 0.73 : radius * 0.80; - const r2 = radius * 0.96; + const r1 = isThreshold ? radius * 0.66 : isMajor ? radius * 0.72 : radius * 0.80; + const r2 = radius * 0.97; + const p1 = polar(cx, cy, r1, a); const p2 = polar(cx, cy, r2, a); @@ -1095,36 +1145,33 @@ const uiHTML = ` if (isThreshold) { ctx.strokeStyle = '#ffffff'; - ctx.lineWidth = 3; + ctx.lineWidth = 3.2; } else if (isMajor) { - ctx.strokeStyle = 'rgba(255,255,255,0.82)'; - ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(255,255,255,0.86)'; + ctx.lineWidth = 2.2; } else { - ctx.strokeStyle = 'rgba(161,161,170,0.72)'; - ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(161,161,170,0.74)'; + ctx.lineWidth = 1.1; } ctx.stroke(); } - const labels = []; - for (let v = 0; v <= GAUGE_MAX_PERCENT; v += 20) labels.push(v); - if (labels[labels.length - 1] !== GAUGE_MAX_PERCENT) labels.push(GAUGE_MAX_PERCENT); - - ctx.fillStyle = 'rgba(244,244,245,0.92)'; - ctx.font = '600 13px Inter, sans-serif'; + const labels = [0, 20, 40, 60, 80, 100, 120, 130]; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = 'rgba(244,244,245,0.96)'; + ctx.font = '700 18px Inter, sans-serif'; for (const v of labels) { const a = valueToAngle(v); - const p = polar(cx, cy, radius * 1.11, a); - ctx.fillText(String(Math.round(v)), p.x, p.y); + const p = polar(cx, cy, radius * 1.13, a); + ctx.fillText(String(v), p.x, p.y); } - const tip = polar(cx, cy, radius * 0.84, valueAngle); - const left = polar(cx, cy, 7, valueAngle + Math.PI / 2); - const right = polar(cx, cy, 7, valueAngle - Math.PI / 2); - const tail = polar(cx, cy, radius * 0.18, valueAngle + Math.PI); + const tip = polar(cx, cy, radius * 0.86, valueAngle); + const left = polar(cx, cy, 8, valueAngle + Math.PI / 2); + const right = polar(cx, cy, 8, valueAngle - Math.PI / 2); + const tail = polar(cx, cy, radius * 0.20, valueAngle + Math.PI); ctx.save(); ctx.beginPath(); @@ -1140,7 +1187,7 @@ const uiHTML = ` ctx.restore(); ctx.beginPath(); - ctx.arc(cx, cy, 13, 0, Math.PI * 2); + ctx.arc(cx, cy, 14, 0, Math.PI * 2); ctx.fillStyle = '#101114'; ctx.fill(); ctx.lineWidth = 3; @@ -1153,32 +1200,27 @@ const uiHTML = ` ctx.fill(); ctx.beginPath(); - ctx.arc(cx, cy + 6, radius * 0.35, 0, Math.PI * 2); - ctx.fillStyle = 'rgba(9,9,11,0.84)'; + ctx.arc(cx, cy + 8, radius * 0.36, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(9,9,11,0.85)'; ctx.fill(); ctx.lineWidth = 1; - ctx.strokeStyle = 'rgba(255,255,255,0.09)'; + ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.stroke(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#ffffff'; - ctx.font = '700 34px Space Grotesk, Inter, sans-serif'; - ctx.fillText(value.toFixed(1), cx, cy - 6); + ctx.font = '700 48px Space Grotesk, Inter, sans-serif'; + ctx.fillText(value.toFixed(1), cx, cy - 8); ctx.fillStyle = sideAccent; - ctx.font = '700 13px Inter, sans-serif'; - ctx.fillText(UNIT_PCT, cx, cy + 22); + ctx.font = '700 18px Inter, sans-serif'; + ctx.fillText(UNIT_PCT, cx, cy + 26); ctx.fillStyle = '#a1a1aa'; - ctx.font = '600 12px Inter, sans-serif'; - ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 42); - } - - function zoneColor(zone, left) { - if (zone === 'critical') return '#ef4444'; - if (zone === 'warning') return '#eab308'; - return left ? '#22d3ee' : '#c084fc'; + ctx.font = '600 16px Inter, sans-serif'; + ctx.fillText((Number(knValue) || 0).toFixed(1) + ' ' + UNIT_FORCE, cx, cy + 52); } function getZone(percentValue) { @@ -1190,6 +1232,7 @@ const uiHTML = ` function setStatusConnected(connected) { const dot = document.getElementById('dot'); const text = document.getElementById('status-text'); + if (connected) { dot.className = 'w-4 h-4 rounded-full bg-emerald-400 ring-4 ring-emerald-400/20'; text.textContent = 'Connected'; @@ -1231,23 +1274,30 @@ const uiHTML = ` if (!isoString) return 'Last update: --:--:--.---'; const d = new Date(isoString); if (isNaN(d.getTime())) return 'Last update: --:--:--.---'; + const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); const ss = String(d.getSeconds()).padStart(2, '0'); const ms = String(d.getMilliseconds()).padStart(3, '0'); + return 'Last update: ' + hh + ':' + mm + ':' + ss + '.' + ms; } function updateAlarmBanner(leftPercent, rightPercent) { const banner = document.getElementById('alarm-banner'); const text = document.getElementById('alarm-text'); + const leftCritical = leftPercent >= CRITICAL_PERCENT; const rightCritical = rightPercent >= 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'; + 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 { banner.classList.add('hidden'); @@ -1256,12 +1306,14 @@ const uiHTML = ` function redrawGauges() { if (!latestData) return; + const leftPercent = Number(latestData.sila_l) || 0; const rightPercent = Number(latestData.sila_r) || 0; const leftKN = Number(latestData.sila_l_kn) || 0; const rightKN = Number(latestData.sila_r_kn) || 0; - drawGauge('gaugeL', leftPercent, leftKN, getZone(leftPercent), '#22d3ee'); - drawGauge('gaugeR', rightPercent, rightKN, getZone(rightPercent), '#c084fc'); + + drawGauge('gaugeL', leftPercent, leftKN, '#22d3ee'); + drawGauge('gaugeR', rightPercent, rightKN, '#c084fc'); } async function fetchLiveData() { @@ -1276,16 +1328,21 @@ const uiHTML = ` const rightKN = Number(d.sila_r_kn) || 0; const sumPercent = Number(d.sum_percent) || 0; const sumKN = Number(d.sum_kn) || 0; + const imbalance = Number(d.imbalance_percent) || 0; + const bias = Number(d.bias_percent) || 0; setStatusConnected(!!d.connected); document.querySelector('#digital-l .percent').textContent = leftPercent.toFixed(1); document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ' + UNIT_FORCE; + document.querySelector('#digital-r .percent').textContent = rightPercent.toFixed(1); document.querySelector('#digital-r .kn').textContent = rightKN.toFixed(1) + ' ' + UNIT_FORCE; document.getElementById('sum-percent').textContent = sumPercent.toFixed(1); document.getElementById('sum-kn').textContent = sumKN.toFixed(1); + document.getElementById('imbalance-pct').textContent = imbalance.toFixed(1); + document.getElementById('bias-pct').textContent = bias.toFixed(1); document.getElementById('last-update').textContent = formatLastUpdate(d.last_update); document.getElementById('dropped-samples').textContent = String(d.dropped_samples || 0); @@ -1302,9 +1359,11 @@ const uiHTML = ` async function fetchHistory() { if (historyBusy) return; historyBusy = true; + try { const res = await fetch('/api/history?window=' + encodeURIComponent(currentWindow), { cache: 'no-store' }); if (!res.ok) throw new Error('History request failed'); + const d = await res.json(); const pts = Array.isArray(d.points) ? d.points : []; const labels = pts.map(p => p.time); @@ -1355,7 +1414,7 @@ const uiHTML = ` labels: [], datasets: [ { - label: 'Sila L %', + label: 'Levi peak %', borderColor: '#22d3ee', backgroundColor: 'rgba(34,211,238,0.10)', borderWidth: 3, @@ -1364,7 +1423,7 @@ const uiHTML = ` data: [] }, { - label: 'Sila R %', + label: 'Desni peak %', borderColor: '#c084fc', backgroundColor: 'rgba(192,132,252,0.10)', borderWidth: 3,