added imbalance and fix gauge sizing

This commit is contained in:
Gamer 2026-04-16 19:02:59 +02:00
parent 5fbc563c36
commit 183a803c3f

305
main.go
View file

@ -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 = `<!DOCTYPE html>
--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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
<p class="text-zinc-400 mt-2 text-base md:text-lg">{{.Subtitle}}</p>
<p class="text-zinc-500 mt-1 text-sm font-mono">MAX_TONNAGE = {{printf "%.1f" .MaxTonnage}} {{.UnitForce}}</p>
</div>
<div class="glass border border-white/10 px-6 py-4 rounded-3xl flex flex-col md:flex-row md:items-center gap-4 md:gap-8 w-fit">
<div class="flex items-center gap-3">
<div id="dot" class="w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20"></div>
@ -858,24 +889,35 @@ const uiHTML = `<!DOCTYPE html>
<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>
<div class="text-zinc-400 text-sm uppercase tracking-[0.25em]">TOTAL FORCE</div>
<div class="text-zinc-400 text-sm uppercase tracking-[0.25em]">TOTAL PEAK FORCE</div>
<div class="mt-2 flex items-end gap-4">
<div class="text-5xl md:text-6xl font-mono font-bold text-emerald-300 leading-none" id="sum-kn">0.0</div>
<div class="text-2xl text-emerald-400 mb-1">{{.UnitForce}}</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 min-w-[280px]">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 min-w-[320px]">
<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">TOTAL %</div>
<div class="text-3xl font-mono font-bold text-sky-200 mt-1"><span id="sum-percent">0.0</span> {{.UnitPct}}</div>
</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">LIMITS</div>
<div class="text-sm font-mono text-zinc-300 mt-2">W {{printf "%.0f" .WarningPercent}}{{.UnitPct}} C {{printf "%.0f" .CriticalPercent}}{{.UnitPct}}</div>
<div class="text-zinc-500 text-xs uppercase tracking-widest">IMBALANCE</div>
<div class="text-3xl font-mono font-bold text-amber-200 mt-1"><span id="imbalance-pct">0.0</span> {{.UnitPct}}</div>
<div class="text-xs text-zinc-500 mt-2 font-mono">abs(L - R)</div>
</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">BIAS</div>
<div class="text-3xl font-mono font-bold text-violet-200 mt-1"><span id="bias-pct">0.0</span> {{.UnitPct}}</div>
<div class="text-xs text-zinc-500 mt-2 font-mono">L - R</div>
</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 non-blocking writer</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>
</div>
</div>
@ -887,16 +929,18 @@ const uiHTML = `<!DOCTYPE html>
<div class="flex items-center gap-4">
<div id="led-l" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
<div>
<h2 class="text-2xl md:text-3xl font-bold tracking-wider">{{.LeftLabel}}</h2>
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.LeftLabel}}</h2>
<div id="state-l" class="text-sm text-zinc-400 mt-1">NORMAL</div>
</div>
</div>
<div id="digital-l" class="text-right">
<div class="percent text-5xl md:text-6xl font-mono font-bold text-sky-100 leading-none">0.0</div>
<div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-sky-100 leading-none">0.0</div>
<div class="text-xl text-sky-400 mt-1">{{.UnitPct}}</div>
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
</div>
</div>
<div class="gauge-container">
<canvas id="gaugeL" class="gauge-canvas"></canvas>
</div>
@ -907,16 +951,18 @@ const uiHTML = `<!DOCTYPE html>
<div class="flex items-center gap-4">
<div id="led-r" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
<div>
<h2 class="text-2xl md:text-3xl font-bold tracking-wider">{{.RightLabel}}</h2>
<h2 class="text-2xl md:text-3xl xl:text-4xl font-bold tracking-wider">{{.RightLabel}}</h2>
<div id="state-r" class="text-sm text-zinc-400 mt-1">NORMAL</div>
</div>
</div>
<div id="digital-r" class="text-right">
<div class="percent text-5xl md:text-6xl font-mono font-bold text-violet-100 leading-none">0.0</div>
<div class="percent text-5xl md:text-6xl xl:text-7xl font-mono font-bold text-violet-100 leading-none">0.0</div>
<div class="text-xl text-violet-400 mt-1">{{.UnitPct}}</div>
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 {{.UnitForce}}</div>
</div>
</div>
<div class="gauge-container">
<canvas id="gaugeR" class="gauge-canvas"></canvas>
</div>
@ -927,9 +973,10 @@ const uiHTML = `<!DOCTYPE html>
<div class="glass border border-white/10 rounded-3xl p-5 md:p-7">
<div class="flex flex-col xl:flex-row gap-5 xl:items-center xl:justify-between mb-5">
<div>
<h2 class="text-2xl md:text-3xl font-semibold">Live Trend</h2>
<div class="text-zinc-400 mt-1 text-sm md:text-base">Chart reads selected history range directly from SQLite</div>
<h2 class="text-2xl md:text-3xl font-semibold">Peak Trend</h2>
<div class="text-zinc-400 mt-1 text-sm md:text-base">Piezo peak/stroke history from SQLite</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<button class="window-btn active px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="30s">30s</button>
<button class="window-btn px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-zinc-300 text-sm font-medium" data-window="1m">1m</button>
@ -944,6 +991,7 @@ const uiHTML = `<!DOCTYPE html>
</div>
</div>
</div>
<div class="h-[52vh] min-h-[420px] max-h-[760px]">
<canvas id="lineChart"></canvas>
</div>
@ -1052,40 +1100,42 @@ const uiHTML = `<!DOCTYPE html>
}
}
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
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 = `<!DOCTYPE html>
data: []
},
{
label: 'Sila R %',
label: 'Desni peak %',
borderColor: '#c084fc',
backgroundColor: 'rgba(192,132,252,0.10)',
borderWidth: 3,