package main import ( "database/sql" "encoding/json" "errors" "fmt" "html/template" "log" "math" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" _ "github.com/mattn/go-sqlite3" "github.com/robinson/gos7" "gopkg.in/yaml.v3" ) type Config struct { Server ServerConfig `yaml:"server"` PLC PLCConfig `yaml:"plc"` Thresholds ThresholdsConfig `yaml:"thresholds"` Trend TrendConfig `yaml:"trend"` Press PressConfig `yaml:"press"` UI UIConfig `yaml:"ui"` DB DBConfig `yaml:"db"` } type ServerConfig struct { ListenAddr string `yaml:"listen_addr"` } type PLCConfig struct { IP string `yaml:"ip"` DBNum int `yaml:"db_num"` Rack int `yaml:"rack"` Slot int `yaml:"slot"` PollMs int `yaml:"poll_ms"` ConnectTimeoutSec int `yaml:"connect_timeout_sec"` IdleTimeoutSec int `yaml:"idle_timeout_sec"` ReconnectDelaySec int `yaml:"reconnect_delay_sec"` } 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"` LegacyMaxKn float64 `yaml:"max_kn,omitempty"` } type TrendConfig struct { Minutes int `yaml:"minutes"` } type PressConfig struct { MAX_TONNAGE float64 `yaml:"MAX_TONNAGE"` LegacyMaxTonnage float64 `yaml:"max_tonnage,omitempty"` } type UIConfig struct { Title string `yaml:"title"` Subtitle string `yaml:"subtitle"` LeftLabel string `yaml:"left_label"` RightLabel string `yaml:"right_label"` UnitForce string `yaml:"unit_force"` UnitPct string `yaml:"unit_percent"` } type DBConfig struct { Path string `yaml:"path"` BusyTimeoutMs int `yaml:"busy_timeout_ms"` BatchSize int `yaml:"batch_size"` FlushIntervalMs int `yaml:"flush_interval_ms"` RetentionDays int `yaml:"retention_days"` MaxChartPoints int `yaml:"max_chart_points"` WriterQueueSize int `yaml:"writer_queue_size"` CheckpointPages int `yaml:"checkpoint_pages"` CleanupIntervalHr int `yaml:"cleanup_interval_hours"` } func defaultConfig() Config { return Config{ Server: ServerConfig{ ListenAddr: ":8080", }, PLC: PLCConfig{ IP: "192.168.0.1", DBNum: 1001, Rack: 0, Slot: 1, PollMs: 500, ConnectTimeoutSec: 5, IdleTimeoutSec: 5, ReconnectDelaySec: 5, }, Thresholds: ThresholdsConfig{ WarningPercent: 80, CriticalPercent: 95, GaugeMaxPercent: 130, ImbalanceWarningPercent: 10, ImbalanceCriticalPercent: 20, }, Trend: TrendConfig{ Minutes: 5, }, Press: PressConfig{ MAX_TONNAGE: 64, }, UI: UIConfig{ 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", UnitForce: "kN", UnitPct: "%", }, DB: DBConfig{ Path: "force_monitor.db", BusyTimeoutMs: 5000, BatchSize: 32, FlushIntervalMs: 1000, RetentionDays: 30, MaxChartPoints: 2000, WriterQueueSize: 4096, CheckpointPages: 1000, CleanupIntervalHr: 6, }, } } func normalizeConfig(cfg *Config) { def := defaultConfig() if strings.TrimSpace(cfg.Server.ListenAddr) == "" { 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 } if cfg.Thresholds.WarningPercent <= 0 && cfg.Thresholds.LegacyWarningKn > 0 { cfg.Thresholds.WarningPercent = cfg.Thresholds.LegacyWarningKn } if cfg.Thresholds.CriticalPercent <= 0 && cfg.Thresholds.LegacyCriticalKn > 0 { cfg.Thresholds.CriticalPercent = cfg.Thresholds.LegacyCriticalKn } 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 } 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 } if cfg.Thresholds.ImbalanceCriticalPercent < cfg.Thresholds.ImbalanceWarningPercent { cfg.Thresholds.ImbalanceCriticalPercent = cfg.Thresholds.ImbalanceWarningPercent } if cfg.Trend.Minutes <= 0 { 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 } 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 } 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 } } func loadOrCreateConfig(configPath string) (Config, error) { cfg := defaultConfig() _, err := os.Stat(configPath) if errors.Is(err, os.ErrNotExist) { data, marshalErr := yaml.Marshal(&cfg) if marshalErr != nil { return cfg, fmt.Errorf("failed to marshal default config: %w", marshalErr) } if writeErr := os.WriteFile(configPath, data, 0644); writeErr != nil { return cfg, fmt.Errorf("failed to create config file: %w", writeErr) } log.Printf("config file not found, created default config: %s", configPath) return cfg, nil } if err != nil { return cfg, fmt.Errorf("failed to stat config file: %w", err) } data, err := os.ReadFile(configPath) if err != nil { return cfg, fmt.Errorf("failed to read config file: %w", err) } if err := yaml.Unmarshal(data, &cfg); err != nil { return cfg, fmt.Errorf("failed to parse config file: %w", err) } normalizeConfig(&cfg) return cfg, nil } type Sample struct { 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 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"` ImbalancePercent float32 `json:"imbalance_percent"` BiasPercent float32 `json:"bias_percent"` LastUpdate string `json:"last_update"` DroppedSamples uint64 `json:"dropped_samples"` } type HistoryPoint struct { Time string `json:"time"` SilaL float32 `json:"sila_l"` SilaR float32 `json:"sila_r"` } type HistoryResponse struct { Window string `json:"window"` Points []HistoryPoint `json:"points"` } type PageData struct { 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 ( cfg Config state AppState db *sql.DB sampleCh chan Sample uiTemplate = template.Must(template.New("ui").Parse(uiHTML)) ) func calculateForces(leftPercent, rightPercent float32) (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 return float32(left), float32(right), float32(sumPct), float32(total) } func snapshotState() APIState { state.RLock() defer state.RUnlock() lastUpdate := "" if !state.LastUpdate.IsZero() { lastUpdate = state.LastUpdate.Format(time.RFC3339Nano) } return APIState{ 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, } } func setDisconnected() { state.Lock() state.Connected = false state.Unlock() } func enqueueSample(s Sample) { select { case sampleCh <- s: default: state.Lock() state.DroppedSamples++ state.Unlock() } } 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, ¬Null, &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) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } database.SetMaxOpenConns(1) database.SetMaxIdleConns(1) database.SetConnMaxLifetime(0) pragmas := []string{ "PRAGMA journal_mode=WAL;", "PRAGMA synchronous=NORMAL;", fmt.Sprintf("PRAGMA wal_autocheckpoint=%d;", cfg.DB.CheckpointPages), fmt.Sprintf("PRAGMA busy_timeout=%d;", cfg.DB.BusyTimeoutMs), "PRAGMA temp_store=MEMORY;", } for _, q := range pragmas { if _, err := database.Exec(q); err != nil { _ = database.Close() return nil, fmt.Errorf("sqlite pragma failed (%s): %w", q, err) } } schema := ` CREATE TABLE IF NOT EXISTS samples ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts DATETIME NOT NULL, sila_l_pct REAL NOT NULL, sila_r_pct REAL NOT NULL, sila_l_kn REAL NOT NULL, sila_r_kn REAL NOT NULL, sum_pct REAL NOT NULL, sum_kn 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); ` if _, err := database.Exec(schema); err != nil { _ = database.Close() 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 } func startDBWriter(database *sql.DB) { ticker := time.NewTicker(time.Duration(cfg.DB.FlushIntervalMs) * time.Millisecond) defer ticker.Stop() batch := make([]Sample, 0, cfg.DB.BatchSize) flush := func() { if len(batch) == 0 { return } tx, err := database.Begin() if err != nil { log.Printf("db begin failed: %v", err) 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, imbalance_pct, bias_pct ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { _ = tx.Rollback() log.Printf("db prepare failed: %v", err) return } ok := true for _, s := range batch { _, err := stmt.Exec( s.TS.UTC().Format(time.RFC3339Nano), s.SilaLPct, s.SilaRPct, s.SilaLKN, s.SilaRKN, s.SumPercent, s.SumKN, s.ImbalancePercent, s.BiasPercent, ) if err != nil { ok = false log.Printf("db insert failed: %v", err) break } } _ = stmt.Close() if !ok { _ = tx.Rollback() return } if err := tx.Commit(); err != nil { log.Printf("db commit failed: %v", err) return } batch = batch[:0] } for { select { case s := <-sampleCh: batch = append(batch, s) if len(batch) >= cfg.DB.BatchSize { flush() } case <-ticker.C: flush() } } } func startDBCleanup(database *sql.DB) { if cfg.DB.RetentionDays <= 0 { return } ticker := time.NewTicker(time.Duration(cfg.DB.CleanupIntervalHr) * time.Hour) defer ticker.Stop() cleanup := func() { cutoff := time.Now().AddDate(0, 0, -cfg.DB.RetentionDays).UTC().Format(time.RFC3339Nano) if _, err := database.Exec(`DELETE FROM samples WHERE ts < ?`, cutoff); err != nil { log.Printf("db cleanup failed: %v", err) } } cleanup() for range ticker.C { cleanup() } } func startPLCPoller() { for { 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 if err := handler.Connect(); err != nil { setDisconnected() log.Printf("PLC connect failed: %v - retrying in %ds...", err, cfg.PLC.ReconnectDelaySec) time.Sleep(time.Duration(cfg.PLC.ReconnectDelaySec) * time.Second) continue } client := gos7.NewClient(handler) log.Println("PLC connected successfully") for { buf := make([]byte, 8) if err := client.AGReadDB(cfg.PLC.DBNum, 0, 8, buf); err != nil { log.Printf("PLC read error: %v - reconnecting...", err) setDisconnected() _ = handler.Close() break } var helper gos7.Helper silaL := helper.GetRealAt(buf, 0) 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() state.Connected = true state.SilaL = silaL state.SilaR = silaR state.SilaLkN = leftKN 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, ImbalancePercent: imbalance, BiasPercent: bias, }) time.Sleep(time.Duration(cfg.PLC.PollMs) * time.Millisecond) } } } func parseWindow(raw string) (time.Duration, string, error) { s := strings.TrimSpace(strings.ToLower(raw)) if s == "" { s = fmt.Sprintf("%dm", cfg.Trend.Minutes) } if strings.HasSuffix(s, "d") { n, err := strconv.Atoi(strings.TrimSuffix(s, "d")) if err != nil || n <= 0 { return 0, "", fmt.Errorf("invalid day window") } d := time.Duration(n) * 24 * time.Hour return d, s, nil } d, err := time.ParseDuration(s) if err != nil || d <= 0 { return 0, "", fmt.Errorf("invalid window") } 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) rows, err := db.Query(`SELECT ts, sila_l_pct, sila_r_pct FROM samples WHERE ts >= ? ORDER BY ts ASC`, cutoff) if err != nil { return nil, err } defer rows.Close() points := make([]HistoryPoint, 0, 1024) for rows.Next() { var ts string var l, r float64 if err := rows.Scan(&ts, &l, &r); err != nil { return nil, err } t, err := time.Parse(time.RFC3339Nano, ts) if err != nil { continue } points = append(points, HistoryPoint{ Time: formatHistoryLabel(t, window), SilaL: float32(l), SilaR: float32(r), }) } if err := rows.Err(); err != nil { return nil, err } if len(points) <= cfg.DB.MaxChartPoints { return points, 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) for i := 0; i < max; i++ { idx := int(float64(i) * step) if idx >= len(points) { idx = len(points) - 1 } if _, ok := used[idx]; ok { continue } used[idx] = struct{}{} out = append(out, points[idx]) } if len(out) == 0 { return points } out[len(out)-1] = points[len(points)-1] return out } func apiData(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(snapshotState()) } func apiHistory(w http.ResponseWriter, r *http.Request) { window, label, err := parseWindow(r.URL.Query().Get("window")) if err != nil { http.Error(w, "invalid window", http.StatusBadRequest) return } points, err := queryHistory(window) if err != nil { http.Error(w, "history query failed", http.StatusInternalServerError) log.Printf("history query failed: %v", err) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(HistoryResponse{ Window: label, Points: points, }) } 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, 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") if err := uiTemplate.Execute(w, data); err != nil { log.Printf("template execute error: %v", err) http.Error(w, "render failed", http.StatusInternalServerError) } } func main() { wd, err := os.Getwd() if err != nil { log.Fatalf("failed to get working directory: %v", err) } configPath := filepath.Join(wd, "config.yaml") cfg, err = loadOrCreateConfig(configPath) if err != nil { log.Fatalf("failed to load config: %v", err) } dbPath := cfg.DB.Path if !filepath.IsAbs(dbPath) { dbPath = filepath.Join(wd, dbPath) } db, err = initDatabase(dbPath) if err != nil { log.Fatalf("failed to init database: %v", err) } defer db.Close() sampleCh = make(chan Sample, cfg.DB.WriterQueueSize) log.Printf("config loaded from: %s", configPath) log.Printf("sqlite 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) go startDBWriter(db) go startDBCleanup(db) go startPLCPoller() http.HandleFunc("/", serveUI) http.HandleFunc("/api/data", apiData) http.HandleFunc("/api/history", apiHistory) log.Println("S7-1200 Force Monitor started") log.Println("VERSION 0.3.0") log.Printf("Open: http://localhost%s", cfg.Server.ListenAddr) log.Fatal(http.ListenAndServe(cfg.Server.ListenAddr, nil)) } const uiHTML = ` {{.Title}}

{{.Title}}

{{.Subtitle}}

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

Disconnected
Last update: --:--:--.---
Dropped queue: 0
FORCE
NO DATA
--
IMBALANCE
NO DATA
--
PLC
OFFLINE
Disconnected
TOTAL PEAK FORCE
0.0
{{.UnitForce}}
TOTAL %
0.0 {{.UnitPct}}
IMBALANCE
0.0 {{.UnitPct}}
abs(L - R)
BIAS
0.0 {{.UnitPct}}
L - R
LIMITS
Force W {{printf "%.0f" .WarningPercent}} / C {{printf "%.0f" .CriticalPercent}}
Imb W {{printf "%.0f" .ImbalanceWarningPercent}} / C {{printf "%.0f" .ImbalanceCriticalPercent}}

{{.LeftLabel}}

NORMAL
0.0
{{.UnitPct}}
0.0 {{.UnitForce}}

{{.RightLabel}}

NORMAL
0.0
{{.UnitPct}}
0.0 {{.UnitForce}}

Peak Trend

Piezo peak/stroke history from SQLite
`