Summary table
#SeverityIssue1–2🔴 BugstartAlarmWriter ignores config batch/flush settings3🔴 Perfbuf allocated in hot PLC loop4🟡 StyleMAX_TONNAGE breaks Go naming5🟡 DRYDuplicate direction classifiers6🟡 Perf8 sequential DB queries in trend handler7🟡 ReliabilityNo graceful shutdown8–11🟢 PolishMinor cleanup items
This commit is contained in:
parent
3c9509169d
commit
8cd6e066e8
438
main.go
438
main.go
|
|
@ -1,6 +1,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
|
|
@ -11,10 +13,12 @@ import (
|
|||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
|
@ -25,6 +29,12 @@ import (
|
|||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
const version = "0.7.1"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
PLC PLCConfig `yaml:"plc"`
|
||||
|
|
@ -66,8 +76,11 @@ type TrendConfig struct {
|
|||
Minutes int `yaml:"minutes"`
|
||||
}
|
||||
|
||||
// PressConfig: Go field is MaxTonnage (idiomatic). YAML tag kept as MAX_TONNAGE
|
||||
// so existing config files need no changes. LegacyMaxTonnage handles old
|
||||
// configs that used the lowercase "max_tonnage" key.
|
||||
type PressConfig struct {
|
||||
MAX_TONNAGE float64 `yaml:"MAX_TONNAGE"`
|
||||
MaxTonnage float64 `yaml:"MAX_TONNAGE"`
|
||||
LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +101,7 @@ type DBConfig struct {
|
|||
RetentionDays int `yaml:"retention_days"`
|
||||
MaxChartPoints int `yaml:"max_chart_points"`
|
||||
WriterQueueSize int `yaml:"writer_queue_size"`
|
||||
AlarmQueueSize int `yaml:"alarm_queue_size"`
|
||||
CheckpointPages int `yaml:"checkpoint_pages"`
|
||||
CleanupIntervalHr int `yaml:"cleanup_interval_hours"`
|
||||
}
|
||||
|
|
@ -118,7 +132,7 @@ func defaultConfig() Config {
|
|||
Minutes: 5,
|
||||
},
|
||||
Press: PressConfig{
|
||||
MAX_TONNAGE: 64,
|
||||
MaxTonnage: 64,
|
||||
},
|
||||
UI: UIConfig{
|
||||
Title: "Force Monitor",
|
||||
|
|
@ -136,38 +150,48 @@ func defaultConfig() Config {
|
|||
RetentionDays: 30,
|
||||
MaxChartPoints: 2000,
|
||||
WriterQueueSize: 4096,
|
||||
AlarmQueueSize: 512,
|
||||
CheckpointPages: 1000,
|
||||
CleanupIntervalHr: 6,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config normalisation helpers — eliminate repetitive if-chains
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func setIfZeroF(dst *float64, def float64) {
|
||||
if *dst <= 0 {
|
||||
*dst = def
|
||||
}
|
||||
}
|
||||
|
||||
func setIfZeroI(dst *int, def int) {
|
||||
if *dst <= 0 {
|
||||
*dst = def
|
||||
}
|
||||
}
|
||||
|
||||
func setIfEmpty(dst *string, def string) {
|
||||
if strings.TrimSpace(*dst) == "" {
|
||||
*dst = def
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeConfig(cfg *Config) {
|
||||
def := defaultConfig()
|
||||
|
||||
if strings.TrimSpace(cfg.Server.ListenAddr) == "" {
|
||||
cfg.Server.ListenAddr = def.Server.ListenAddr
|
||||
}
|
||||
setIfEmpty(&cfg.Server.ListenAddr, def.Server.ListenAddr)
|
||||
|
||||
if strings.TrimSpace(cfg.PLC.IP) == "" {
|
||||
cfg.PLC.IP = def.PLC.IP
|
||||
}
|
||||
if cfg.PLC.DBNum <= 0 {
|
||||
cfg.PLC.DBNum = def.PLC.DBNum
|
||||
}
|
||||
if cfg.PLC.PollMs <= 0 {
|
||||
cfg.PLC.PollMs = def.PLC.PollMs
|
||||
}
|
||||
if cfg.PLC.ConnectTimeoutSec <= 0 {
|
||||
cfg.PLC.ConnectTimeoutSec = def.PLC.ConnectTimeoutSec
|
||||
}
|
||||
if cfg.PLC.IdleTimeoutSec <= 0 {
|
||||
cfg.PLC.IdleTimeoutSec = def.PLC.IdleTimeoutSec
|
||||
}
|
||||
if cfg.PLC.ReconnectDelaySec <= 0 {
|
||||
cfg.PLC.ReconnectDelaySec = def.PLC.ReconnectDelaySec
|
||||
}
|
||||
setIfEmpty(&cfg.PLC.IP, def.PLC.IP)
|
||||
setIfZeroI(&cfg.PLC.DBNum, def.PLC.DBNum)
|
||||
setIfZeroI(&cfg.PLC.PollMs, def.PLC.PollMs)
|
||||
setIfZeroI(&cfg.PLC.ConnectTimeoutSec, def.PLC.ConnectTimeoutSec)
|
||||
setIfZeroI(&cfg.PLC.IdleTimeoutSec, def.PLC.IdleTimeoutSec)
|
||||
setIfZeroI(&cfg.PLC.ReconnectDelaySec, def.PLC.ReconnectDelaySec)
|
||||
|
||||
// Legacy threshold key migration (warning_kn / critical_kn / max_kn)
|
||||
if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 {
|
||||
cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn
|
||||
}
|
||||
|
|
@ -178,82 +202,41 @@ func normalizeConfig(cfg *Config) {
|
|||
cfg.Thresholds.GaugeMaxPercent = cfg.Thresholds.LegacyMaxKn
|
||||
}
|
||||
|
||||
if cfg.Thresholds.WarningPercent <= 0 {
|
||||
cfg.Thresholds.WarningPercent = def.Thresholds.WarningPercent
|
||||
}
|
||||
if cfg.Thresholds.CriticalPercent <= 0 {
|
||||
cfg.Thresholds.CriticalPercent = def.Thresholds.CriticalPercent
|
||||
}
|
||||
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
|
||||
}
|
||||
setIfZeroF(&cfg.Thresholds.WarningPercent, def.Thresholds.WarningPercent)
|
||||
setIfZeroF(&cfg.Thresholds.CriticalPercent, def.Thresholds.CriticalPercent)
|
||||
setIfZeroF(&cfg.Thresholds.GaugeMaxPercent, def.Thresholds.GaugeMaxPercent)
|
||||
setIfZeroF(&cfg.Thresholds.ImbalanceWarningPercent, def.Thresholds.ImbalanceWarningPercent)
|
||||
setIfZeroF(&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
|
||||
}
|
||||
setIfZeroI(&cfg.Trend.Minutes, def.Trend.Minutes)
|
||||
|
||||
if cfg.Press.MAX_TONNAGE <= 0 && cfg.Press.LegacyMaxTonnage > 0 {
|
||||
cfg.Press.MAX_TONNAGE = cfg.Press.LegacyMaxTonnage
|
||||
}
|
||||
if cfg.Press.MAX_TONNAGE <= 0 {
|
||||
cfg.Press.MAX_TONNAGE = def.Press.MAX_TONNAGE
|
||||
// Legacy press tonnage key migration (max_tonnage lowercase)
|
||||
if cfg.Press.MaxTonnage <= 0 && cfg.Press.LegacyMaxTonnage > 0 {
|
||||
cfg.Press.MaxTonnage = cfg.Press.LegacyMaxTonnage
|
||||
}
|
||||
setIfZeroF(&cfg.Press.MaxTonnage, def.Press.MaxTonnage)
|
||||
|
||||
if strings.TrimSpace(cfg.UI.Title) == "" {
|
||||
cfg.UI.Title = def.UI.Title
|
||||
}
|
||||
if strings.TrimSpace(cfg.UI.Subtitle) == "" {
|
||||
cfg.UI.Subtitle = def.UI.Subtitle
|
||||
}
|
||||
if strings.TrimSpace(cfg.UI.LeftLabel) == "" {
|
||||
cfg.UI.LeftLabel = def.UI.LeftLabel
|
||||
}
|
||||
if strings.TrimSpace(cfg.UI.RightLabel) == "" {
|
||||
cfg.UI.RightLabel = def.UI.RightLabel
|
||||
}
|
||||
if strings.TrimSpace(cfg.UI.UnitForce) == "" {
|
||||
cfg.UI.UnitForce = def.UI.UnitForce
|
||||
}
|
||||
if strings.TrimSpace(cfg.UI.UnitPct) == "" {
|
||||
cfg.UI.UnitPct = def.UI.UnitPct
|
||||
}
|
||||
setIfEmpty(&cfg.UI.Title, def.UI.Title)
|
||||
setIfEmpty(&cfg.UI.Subtitle, def.UI.Subtitle)
|
||||
setIfEmpty(&cfg.UI.LeftLabel, def.UI.LeftLabel)
|
||||
setIfEmpty(&cfg.UI.RightLabel, def.UI.RightLabel)
|
||||
setIfEmpty(&cfg.UI.UnitForce, def.UI.UnitForce)
|
||||
setIfEmpty(&cfg.UI.UnitPct, def.UI.UnitPct)
|
||||
|
||||
if strings.TrimSpace(cfg.DB.Path) == "" {
|
||||
cfg.DB.Path = def.DB.Path
|
||||
}
|
||||
if cfg.DB.BusyTimeoutMs <= 0 {
|
||||
cfg.DB.BusyTimeoutMs = def.DB.BusyTimeoutMs
|
||||
}
|
||||
if cfg.DB.BatchSize <= 0 {
|
||||
cfg.DB.BatchSize = def.DB.BatchSize
|
||||
}
|
||||
if cfg.DB.FlushIntervalMs <= 0 {
|
||||
cfg.DB.FlushIntervalMs = def.DB.FlushIntervalMs
|
||||
}
|
||||
if cfg.DB.RetentionDays <= 0 {
|
||||
cfg.DB.RetentionDays = def.DB.RetentionDays
|
||||
}
|
||||
if cfg.DB.MaxChartPoints <= 0 {
|
||||
cfg.DB.MaxChartPoints = def.DB.MaxChartPoints
|
||||
}
|
||||
if cfg.DB.WriterQueueSize <= 0 {
|
||||
cfg.DB.WriterQueueSize = def.DB.WriterQueueSize
|
||||
}
|
||||
if cfg.DB.CheckpointPages <= 0 {
|
||||
cfg.DB.CheckpointPages = def.DB.CheckpointPages
|
||||
}
|
||||
if cfg.DB.CleanupIntervalHr <= 0 {
|
||||
cfg.DB.CleanupIntervalHr = def.DB.CleanupIntervalHr
|
||||
}
|
||||
setIfEmpty(&cfg.DB.Path, def.DB.Path)
|
||||
setIfZeroI(&cfg.DB.BusyTimeoutMs, def.DB.BusyTimeoutMs)
|
||||
setIfZeroI(&cfg.DB.BatchSize, def.DB.BatchSize)
|
||||
setIfZeroI(&cfg.DB.FlushIntervalMs, def.DB.FlushIntervalMs)
|
||||
setIfZeroI(&cfg.DB.RetentionDays, def.DB.RetentionDays)
|
||||
setIfZeroI(&cfg.DB.MaxChartPoints, def.DB.MaxChartPoints)
|
||||
setIfZeroI(&cfg.DB.WriterQueueSize, def.DB.WriterQueueSize)
|
||||
setIfZeroI(&cfg.DB.AlarmQueueSize, def.DB.AlarmQueueSize)
|
||||
setIfZeroI(&cfg.DB.CheckpointPages, def.DB.CheckpointPages)
|
||||
setIfZeroI(&cfg.DB.CleanupIntervalHr, def.DB.CleanupIntervalHr)
|
||||
}
|
||||
|
||||
func loadOrCreateConfig(configPath string) (Config, error) {
|
||||
|
|
@ -287,6 +270,10 @@ func loadOrCreateConfig(configPath string) (Config, error) {
|
|||
return cfg, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Sample struct {
|
||||
TS time.Time
|
||||
SilaLPct float32
|
||||
|
|
@ -431,6 +418,10 @@ type AlarmTracker struct {
|
|||
ImbZone string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Package-level singletons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
cfg Config
|
||||
state AppState
|
||||
|
|
@ -439,20 +430,29 @@ var (
|
|||
alarmCh chan AlarmEvent
|
||||
alarmTracker AlarmTracker
|
||||
uiTemplate = template.Must(template.New("ui").Parse(uiHTML))
|
||||
cachedUI []byte // pre-rendered template (PageData is immutable after startup)
|
||||
)
|
||||
|
||||
func calculateForces(leftPercent, rightPercent float32) (leftKN, rightKN, sumPercent, sumKN float32) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Force calculation — accepts maxTonnage explicitly (testable, no global dep)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func calculateForces(leftPercent, rightPercent float32, maxTonnage float64) (leftKN, rightKN, sumPercent, sumKN float32) {
|
||||
lp := float64(leftPercent)
|
||||
rp := float64(rightPercent)
|
||||
|
||||
sumPct := (lp + rp) / 2.0
|
||||
left := (lp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0)
|
||||
right := (rp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0)
|
||||
total := (sumPct / 100.0) * cfg.Press.MAX_TONNAGE
|
||||
left := (lp / 100.0) * (maxTonnage / 2.0)
|
||||
right := (rp / 100.0) * (maxTonnage / 2.0)
|
||||
total := (sumPct / 100.0) * maxTonnage
|
||||
|
||||
return float32(left), float32(right), float32(sumPct), float32(total)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func snapshotState() APIState {
|
||||
state.RLock()
|
||||
defer state.RUnlock()
|
||||
|
|
@ -505,6 +505,13 @@ func enqueueAlarm(a AlarmEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database initialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ensureColumn adds a column to tableName if it does not already exist.
|
||||
// NOTE: tableName and columnName are always hardcoded call-site constants —
|
||||
// never derived from user input — so fmt.Sprintf is safe here.
|
||||
func ensureColumn(database *sql.DB, tableName, columnName, definition string) error {
|
||||
rows, err := database.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
|
||||
if err != nil {
|
||||
|
|
@ -515,8 +522,7 @@ func ensureColumn(database *sql.DB, tableName, columnName, definition string) er
|
|||
found := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name string
|
||||
var ctype string
|
||||
var name, ctype string
|
||||
var notNull int
|
||||
var dfltValue sql.NullString
|
||||
var pk int
|
||||
|
|
@ -639,7 +645,11 @@ CREATE INDEX IF NOT EXISTS idx_alarm_events_ts_unix_ns ON alarm_events(ts_unix_n
|
|||
return database, nil
|
||||
}
|
||||
|
||||
func startDBWriter(database *sql.DB) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB writer goroutines — both now respect config values and context shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func startDBWriter(ctx context.Context, database *sql.DB) {
|
||||
ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
|
@ -705,6 +715,17 @@ func startDBWriter(database *sql.DB) {
|
|||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Drain any remaining queued samples before exit
|
||||
for {
|
||||
select {
|
||||
case s := <-sampleCh:
|
||||
batch = append(batch, s)
|
||||
default:
|
||||
flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
case s := <-sampleCh:
|
||||
batch = append(batch, s)
|
||||
if len(batch) >= cfg.DB.BatchSize {
|
||||
|
|
@ -716,11 +737,12 @@ func startDBWriter(database *sql.DB) {
|
|||
}
|
||||
}
|
||||
|
||||
func startAlarmWriter(database *sql.DB) {
|
||||
ticker := time.NewTicker(1000 * time.Millisecond)
|
||||
func startAlarmWriter(ctx context.Context, database *sql.DB) {
|
||||
// BUG FIX: was hardcoded 1000ms / 32 — now uses the same config values as startDBWriter
|
||||
ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
batch := make([]AlarmEvent, 0, 32)
|
||||
batch := make([]AlarmEvent, 0, cfg.DB.BatchSize)
|
||||
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
|
|
@ -778,11 +800,22 @@ func startAlarmWriter(database *sql.DB) {
|
|||
batch = batch[:0]
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Drain remaining alarm events before exit
|
||||
for {
|
||||
select {
|
||||
case a := <-alarmCh:
|
||||
batch = append(batch, a)
|
||||
if len(batch) >= 32 {
|
||||
default:
|
||||
flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
case a := <-alarmCh:
|
||||
batch = append(batch, a)
|
||||
if len(batch) >= cfg.DB.BatchSize {
|
||||
flush()
|
||||
}
|
||||
case <-ticker.C:
|
||||
|
|
@ -791,7 +824,7 @@ func startAlarmWriter(database *sql.DB) {
|
|||
}
|
||||
}
|
||||
|
||||
func startDBCleanup(database *sql.DB) {
|
||||
func startDBCleanup(ctx context.Context, database *sql.DB) {
|
||||
if cfg.DB.RetentionDays <= 0 {
|
||||
return
|
||||
}
|
||||
|
|
@ -811,10 +844,19 @@ func startDBCleanup(database *sql.DB) {
|
|||
|
||||
cleanup()
|
||||
|
||||
for range ticker.C {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alarm zone helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func zoneFromValue(value float64, warn, crit float64) string {
|
||||
if value >= crit {
|
||||
|
|
@ -974,10 +1016,7 @@ func maybeLogPLCDisconnected(reason string) {
|
|||
alarmTracker.Lock()
|
||||
defer alarmTracker.Unlock()
|
||||
|
||||
if !alarmTracker.PLCKnown {
|
||||
return
|
||||
}
|
||||
if !alarmTracker.PLCConnected {
|
||||
if !alarmTracker.PLCKnown || !alarmTracker.PLCConnected {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -998,8 +1037,22 @@ func maybeLogPLCDisconnected(reason string) {
|
|||
})
|
||||
}
|
||||
|
||||
func startPLCPoller() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// PLC poller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func startPLCPoller(ctx context.Context) {
|
||||
pollInterval := time.Duration(cfg.PLC.PollMs) * time.Millisecond
|
||||
reconnectDelay := time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second
|
||||
|
||||
for {
|
||||
// Check for shutdown before attempting a new connection
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
handler := gos7.NewTCPClientHandler(cfg.PLC.IP, cfg.PLC.Rack, cfg.PLC.Slot)
|
||||
handler.Timeout = time.Duration(cfg.PLC.ConnectTimeoutSec) * time.Second
|
||||
handler.IdleTimeout = time.Duration(cfg.PLC.IdleTimeoutSec) * time.Second
|
||||
|
|
@ -1007,7 +1060,11 @@ func startPLCPoller() {
|
|||
if err := handler.Connect(); err != nil {
|
||||
markDisconnected(err.Error())
|
||||
log.Printf("PLC connect failed: %v - retrying in %ds...", err, cfg.PLC.ReconnectDelaySec)
|
||||
time.Sleep(time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(reconnectDelay):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -1016,8 +1073,18 @@ func startPLCPoller() {
|
|||
client := gos7.NewClient(handler)
|
||||
log.Println("PLC connected successfully")
|
||||
|
||||
for {
|
||||
// BUG FIX: buf was allocated inside the inner loop, causing a heap
|
||||
// allocation every poll cycle. Moved outside — reused each iteration.
|
||||
buf := make([]byte, 8)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = handler.Close()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err := client.AGReadDB(cfg.PLC.DBNum, 0, 8, buf); err != nil {
|
||||
log.Printf("PLC read error: %v - reconnecting...", err)
|
||||
markDisconnected(err.Error())
|
||||
|
|
@ -1029,7 +1096,7 @@ func startPLCPoller() {
|
|||
silaL := helper.GetRealAt(buf, 0)
|
||||
silaR := helper.GetRealAt(buf, 4)
|
||||
|
||||
leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR)
|
||||
leftKN, rightKN, sumPercent, sumKN := calculateForces(silaL, silaR, cfg.Press.MaxTonnage)
|
||||
imbalance := float32(math.Abs(float64(silaL - silaR)))
|
||||
bias := silaL - silaR
|
||||
now := time.Now()
|
||||
|
|
@ -1062,10 +1129,19 @@ func startPLCPoller() {
|
|||
evaluateProcessAlarms(sample)
|
||||
enqueueSample(sample)
|
||||
|
||||
time.Sleep(time.Duration(cfg.PLC.PollMs) * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = handler.Close()
|
||||
return
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func parseWindow(raw string) (time.Duration, string, error) {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
|
|
@ -1197,30 +1273,28 @@ func queryNumericStats(field string, fromNs, toNs int64) (NumericStats, error) {
|
|||
return stats, nil
|
||||
}
|
||||
|
||||
func classifyForceDirection(delta float64, oldCount, newCount int) string {
|
||||
if oldCount < 3 || newCount < 3 {
|
||||
return "insufficient_data"
|
||||
}
|
||||
if math.Abs(delta) < 1.0 {
|
||||
return "stable"
|
||||
}
|
||||
if delta > 0 {
|
||||
return "rising"
|
||||
}
|
||||
return "falling"
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trend / stability classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func classifyImbalanceDirection(delta float64, oldCount, newCount int) string {
|
||||
// classifyDirection is a single generic direction classifier that replaces
|
||||
// the two near-identical classifyForceDirection / classifyImbalanceDirection
|
||||
// functions that existed previously.
|
||||
//
|
||||
// stableThreshold — abs(delta) below this value → "stable"
|
||||
// posLabel — label when delta > threshold (e.g. "rising", "worsening")
|
||||
// negLabel — label when delta < -threshold (e.g. "falling", "improving")
|
||||
func classifyDirection(delta float64, oldCount, newCount int, stableThreshold float64, posLabel, negLabel string) string {
|
||||
if oldCount < 3 || newCount < 3 {
|
||||
return "insufficient_data"
|
||||
}
|
||||
if math.Abs(delta) < 0.5 {
|
||||
if math.Abs(delta) < stableThreshold {
|
||||
return "stable"
|
||||
}
|
||||
if delta > 0 {
|
||||
return "worsening"
|
||||
return posLabel
|
||||
}
|
||||
return "improving"
|
||||
return negLabel
|
||||
}
|
||||
|
||||
func classifyProcessStability(forceStd, imbStd, forceDelta, avgImb5m float64, sampleCount int) (string, string) {
|
||||
|
|
@ -1299,8 +1373,9 @@ func buildTrendResponse(window time.Duration, label string) (TrendResponse, erro
|
|||
|
||||
forceDelta := forceNew.Avg - forceOld.Avg
|
||||
imbDelta := imbNew.Avg - imbOld.Avg
|
||||
forceDirection := classifyForceDirection(forceDelta, forceOld.Count, forceNew.Count)
|
||||
imbDirection := classifyImbalanceDirection(imbDelta, imbOld.Count, imbNew.Count)
|
||||
|
||||
forceDirection := classifyDirection(forceDelta, forceOld.Count, forceNew.Count, 1.0, "rising", "falling")
|
||||
imbDirection := classifyDirection(imbDelta, imbOld.Count, imbNew.Count, 0.5, "worsening", "improving")
|
||||
|
||||
fullWindowForce, err := queryNumericStats("sum_pct", startNs, nowNs)
|
||||
if err != nil {
|
||||
|
|
@ -1360,13 +1435,8 @@ func queryAlarmEvents(limit int) ([]AlarmEventAPI, error) {
|
|||
|
||||
events := make([]AlarmEventAPI, 0, limit)
|
||||
for rows.Next() {
|
||||
var ts string
|
||||
var severity string
|
||||
var source string
|
||||
var state string
|
||||
var message string
|
||||
var value float64
|
||||
var limitValue float64
|
||||
var ts, severity, source, state, message string
|
||||
var value, limitValue float64
|
||||
|
||||
if err := rows.Scan(&ts, &severity, &source, &state, &message, &value, &limitValue); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -1395,6 +1465,10 @@ func queryAlarmEvents(limit int) ([]AlarmEventAPI, error) {
|
|||
return events, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func apiData(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
|
@ -1464,7 +1538,15 @@ func apiAlarms(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
// serveUI serves the pre-rendered UI page. PageData is derived solely from
|
||||
// the immutable config, so we render the template once at startup and reuse.
|
||||
func serveUI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(cachedUI)
|
||||
}
|
||||
|
||||
// initCachedUI renders the HTML template once at startup.
|
||||
func initCachedUI() {
|
||||
data := PageData{
|
||||
Title: cfg.UI.Title,
|
||||
Subtitle: cfg.UI.Subtitle,
|
||||
|
|
@ -1472,7 +1554,7 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
|
|||
RightLabel: cfg.UI.RightLabel,
|
||||
UnitForce: cfg.UI.UnitForce,
|
||||
UnitPct: cfg.UI.UnitPct,
|
||||
MaxTonnage: cfg.Press.MAX_TONNAGE,
|
||||
MaxTonnage: cfg.Press.MaxTonnage,
|
||||
WarningPercent: cfg.Thresholds.WarningPercent,
|
||||
CriticalPercent: cfg.Thresholds.CriticalPercent,
|
||||
GaugeMaxPercent: cfg.Thresholds.GaugeMaxPercent,
|
||||
|
|
@ -1483,13 +1565,17 @@ func serveUI(w http.ResponseWriter, r *http.Request) {
|
|||
DefaultTrendWindow: "15m",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := uiTemplate.Execute(w, data); err != nil {
|
||||
log.Printf("template execute error: %v", err)
|
||||
http.Error(w, "render failed", http.StatusInternalServerError)
|
||||
var buf bytes.Buffer
|
||||
if err := uiTemplate.Execute(&buf, data); err != nil {
|
||||
log.Fatalf("failed to pre-render UI template: %v", err)
|
||||
}
|
||||
cachedUI = buf.Bytes()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func main() {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
|
|
@ -1514,30 +1600,60 @@ func main() {
|
|||
defer db.Close()
|
||||
|
||||
sampleCh = make(chan Sample, cfg.DB.WriterQueueSize)
|
||||
alarmCh = make(chan AlarmEvent, 512)
|
||||
alarmCh = make(chan AlarmEvent, cfg.DB.AlarmQueueSize) // BUG FIX: was hardcoded 512
|
||||
|
||||
log.Printf("config loaded from: %s", configPath)
|
||||
log.Printf("sqlite db: %s", dbPath)
|
||||
initCachedUI()
|
||||
|
||||
log.Printf("S7-1200 Force Monitor v%s", version)
|
||||
log.Printf("Config: %s", configPath)
|
||||
log.Printf("DB: %s", dbPath)
|
||||
log.Printf("PLC: ip=%s db=%d rack=%d slot=%d poll=%dms",
|
||||
cfg.PLC.IP, cfg.PLC.DBNum, cfg.PLC.Rack, cfg.PLC.Slot, cfg.PLC.PollMs)
|
||||
log.Printf("Press MAX_TONNAGE: %.2f %s", cfg.Press.MAX_TONNAGE, cfg.UI.UnitForce)
|
||||
log.Printf("Press: MAX_TONNAGE=%.2f %s", cfg.Press.MaxTonnage, cfg.UI.UnitForce)
|
||||
|
||||
go startDBWriter(db)
|
||||
go startAlarmWriter(db)
|
||||
go startDBCleanup(db)
|
||||
go startPLCPoller()
|
||||
// Graceful shutdown via SIGINT / SIGTERM
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
http.Handle("/static/", http.FileServer(http.FS(staticFiles)))
|
||||
http.HandleFunc("/", serveUI)
|
||||
http.HandleFunc("/api/data", apiData)
|
||||
http.HandleFunc("/api/history", apiHistory)
|
||||
http.HandleFunc("/api/trend", apiTrend)
|
||||
http.HandleFunc("/api/alarms", apiAlarms)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(4)
|
||||
go func() { defer wg.Done(); startDBWriter(ctx, db) }()
|
||||
go func() { defer wg.Done(); startAlarmWriter(ctx, db) }()
|
||||
go func() { defer wg.Done(); startDBCleanup(ctx, db) }()
|
||||
go func() { defer wg.Done(); startPLCPoller(ctx) }()
|
||||
|
||||
log.Println("S7-1200 Force Monitor started")
|
||||
log.Println("VERSION 0.7.0")
|
||||
log.Printf("Open: http://localhost%s", cfg.Server.ListenAddr)
|
||||
log.Fatal(http.ListenAndServe(cfg.Server.ListenAddr, nil))
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/static/", http.FileServer(http.FS(staticFiles)))
|
||||
mux.HandleFunc("/", serveUI)
|
||||
mux.HandleFunc("/api/data", apiData)
|
||||
mux.HandleFunc("/api/history", apiHistory)
|
||||
mux.HandleFunc("/api/trend", apiTrend)
|
||||
mux.HandleFunc("/api/alarms", apiAlarms)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Server.ListenAddr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
log.Printf("Listening on http://localhost%s", cfg.Server.ListenAddr)
|
||||
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("HTTP server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("Shutting down — flushing DB writers...")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("HTTP server shutdown error: %v", err)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
log.Println("Shutdown complete")
|
||||
}
|
||||
|
||||
const uiHTML = `<!DOCTYPE html>
|
||||
|
|
|
|||
Loading…
Reference in a new issue