commit 5ac16cd7ce6a28380d09fe011b70c2a7309b330e Author: Dejan Rožič Date: Thu Apr 16 13:51:18 2026 +0200 first commit diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..f2e6e3e --- /dev/null +++ b/config.yaml @@ -0,0 +1,26 @@ +server: + listen_addr: :8080 +plc: + ip: 192.168.0.1 + db_num: 1001 + rack: 0 + slot: 1 + poll_ms: 500 + connect_timeout_sec: 5 + idle_timeout_sec: 5 + reconnect_delay_sec: 5 +thresholds: + warning_percent: 80 + critical_percent: 95 + gauge_max_percent: 130 +trend: + minutes: 5 +press: + MAX_TONNAGE: 63 +ui: + title: Force Monitor + subtitle: Siemens S7-1215C • Live monitoring • PLC values in % • kN calculated from MAX_TONNAGE + left_label: LEVI STEBER + right_label: DESNI STEBER + unit_force: kN + unit_percent: '%' diff --git a/main.go b/main.go new file mode 100644 index 0000000..915a184 --- /dev/null +++ b/main.go @@ -0,0 +1,852 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + htmlstd "html" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/robinson/gos7" + "gopkg.in/yaml.v3" +) + +// ============================================= +// Configuration +// ============================================= +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"` +} + +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"` + + // legacy compatibility with previous config names + 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"` + + // optional legacy compatibility + 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"` +} + +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.0, + CriticalPercent: 95.0, + GaugeMaxPercent: 130.0, + }, + Trend: TrendConfig{ + Minutes: 5, + }, + Press: PressConfig{ + MAX_TONNAGE: 64.0, + }, + UI: UIConfig{ + Title: "Force Monitor", + Subtitle: "Siemens S7-1215C • Live monitoring • PLC values in % • kN calculated from MAX_TONNAGE", + LeftLabel: "LEVI STEBER", + RightLabel: "DESNI STEBER", + UnitForce: "kN", + UnitPct: "%", + }, + } +} + +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 + } + + // backward compatibility with old names + 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.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 + } +} + +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 +} + +func getMaxHistoryPoints(cfg Config) int { + points := (cfg.Trend.Minutes*60*1000/cfg.PLC.PollMs + 2) + if points < 10 { + return 10 + } + return points +} + +// ============================================= +// Data Structures +// ============================================= +type Measurement struct { + Time string `json:"time"` + SilaL float32 `json:"sila_l"` // % + SilaR float32 `json:"sila_r"` // % +} + +type AppState struct { + sync.Mutex + Connected bool + SilaL float32 // % + SilaR float32 // % + + SilaLkN float32 + SilaRkN float32 + SumPercent float32 + SumkN float32 + + History []Measurement + LastUpdate time.Time +} + +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"` + History []Measurement `json:"history"` + LastUpdate string `json:"last_update"` +} + +var ( + state AppState + cfg Config +) + +// ============================================= +// Helpers +// ============================================= +func esc(s string) string { + return htmlstd.EscapeString(s) +} + +func setDisconnected() { + state.Lock() + state.Connected = false + state.Unlock() +} + +func snapshotState() APIState { + state.Lock() + defer state.Unlock() + + historyCopy := make([]Measurement, len(state.History)) + copy(historyCopy, state.History) + + 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, + History: historyCopy, + LastUpdate: lastUpdate, + } +} + +func calculateForces(leftPercent, rightPercent float32) (leftKN, rightKN, sumPercent, sumKN float32) { + lp := float64(leftPercent) + rp := float64(rightPercent) + + sumPct := (lp + rp) / 2.0 + + // each column contributes half of total nominal press force + left := (lp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0) + right := (rp / 100.0) * (cfg.Press.MAX_TONNAGE / 2.0) + + // total force by your rule + total := (sumPct / 100.0) * cfg.Press.MAX_TONNAGE + + return float32(left), float32(right), float32(sumPct), float32(total) +} + +// ============================================= +// PLC Poller +// ============================================= +func startPLCPoller() { + maxHistoryPoints := getMaxHistoryPoints(cfg) + + 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) + + now := time.Now() + ts := now.Format("15:04:05.000") + + state.Lock() + state.Connected = true + state.SilaL = silaL + state.SilaR = silaR + state.SilaLkN = leftKN + state.SilaRkN = rightKN + state.SumPercent = sumPercent + state.SumkN = sumKN + state.LastUpdate = now + + state.History = append(state.History, Measurement{ + Time: ts, + SilaL: silaL, + SilaR: silaR, + }) + + if len(state.History) > maxHistoryPoints { + state.History = state.History[len(state.History)-maxHistoryPoints:] + } + state.Unlock() + + time.Sleep(time.Duration(cfg.PLC.PollMs) * time.Millisecond) + } + } +} + +// ============================================= +// HTTP Handlers +// ============================================= +func apiData(w http.ResponseWriter, r *http.Request) { + resp := snapshotState() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(resp) +} + +func serveUI(w http.ResponseWriter, r *http.Request) { + maxHistoryPoints := getMaxHistoryPoints(cfg) + + html := ` + + + + + ` + esc(cfg.UI.Title) + ` + + + + + +
+ + +
+
+

+ ` + esc(cfg.UI.Title) + ` +

+

` + esc(cfg.UI.Subtitle) + `

+

MAX_TONNAGE = ` + fmt.Sprintf("%.1f", cfg.Press.MAX_TONNAGE) + ` ` + esc(cfg.UI.UnitForce) + `

+
+ +
+
+
+ Disconnected +
+
+
Last update: --:--:--.---
+
+
+ +
+
+
+
TOTAL FORCE
+
+
0.0
+
` + esc(cfg.UI.UnitForce) + `
+
+
+
+
+
TOTAL %
+
0.0 ` + esc(cfg.UI.UnitPct) + `
+
+
+
FORMULA
+
(L + R) / 2
+
+
+
+
+ +
+
+
+
+
+
+

` + esc(cfg.UI.LeftLabel) + `

+
NORMAL
+
+
+
+
0.0
+
` + esc(cfg.UI.UnitPct) + `
+
0.0 ` + esc(cfg.UI.UnitForce) + `
+
+
+ +
+ +
+ +
+ 0 + ` + fmt.Sprintf("%.0f", cfg.Thresholds.WarningPercent) + `% + ` + fmt.Sprintf("%.0f", cfg.Thresholds.CriticalPercent) + `% + ` + fmt.Sprintf("%.0f", cfg.Thresholds.GaugeMaxPercent) + `% +
+
+ +
+
+
+
+
+

` + esc(cfg.UI.RightLabel) + `

+
NORMAL
+
+
+
+
0.0
+
` + esc(cfg.UI.UnitPct) + `
+
0.0 ` + esc(cfg.UI.UnitForce) + `
+
+
+ +
+ +
+ +
+ 0 + ` + fmt.Sprintf("%.0f", cfg.Thresholds.WarningPercent) + `% + ` + fmt.Sprintf("%.0f", cfg.Thresholds.CriticalPercent) + `% + ` + fmt.Sprintf("%.0f", cfg.Thresholds.GaugeMaxPercent) + `% +
+
+
+ +
+
+

` + fmt.Sprintf("%d", cfg.Trend.Minutes) + `-Minute Live Trend

+
Chart = percent only • FIFO • ` + fmt.Sprintf("%d", maxHistoryPoints) + ` points max • ` + fmt.Sprintf("%d", cfg.PLC.PollMs) + ` ms poll
+
+
+ +
+
+
+ + + +` + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, html) +} + +// ============================================= +// Main +// ============================================= +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) + } + + log.Printf("config loaded from: %s", configPath) + 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 startPLCPoller() + + http.HandleFunc("/", serveUI) + http.HandleFunc("/api/data", apiData) + + log.Println("S7-1200 Force Monitor started") + log.Printf("Open: http://localhost%s", cfg.Server.ListenAddr) + log.Fatal(http.ListenAndServe(cfg.Server.ListenAddr, nil)) +}