package main import ( "encoding/json" "errors" "fmt" "html/template" "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 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"` // 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"` } type PageData struct { Title string Subtitle string LeftLabel string RightLabel string UnitForce string UnitPct string MaxTonnage float64 WarningPercent float64 CriticalPercent float64 GaugeMaxPercent float64 TrendMinutes int MaxHistoryPoints int PollMs int } var ( state AppState cfg Config uiTmpl = template.Must(template.New("ui").Parse(uiHTML)) ) // ============================================= // Helpers // ============================================= 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 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) { 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, TrendMinutes: cfg.Trend.Minutes, MaxHistoryPoints: getMaxHistoryPoints(cfg), PollMs: cfg.PLC.PollMs, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := uiTmpl.Execute(w, data); err != nil { http.Error(w, "failed to render UI", http.StatusInternalServerError) log.Printf("template execute error: %v", err) } } // ============================================= // 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)) } // ============================================= // Embedded UI Template // ============================================= const uiHTML = ` {{.Title}}

{{.Title}}

{{.Subtitle}}

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

Disconnected
Last update: --:--:--.---
TOTAL FORCE
0.0
{{.UnitForce}}
TOTAL %
0.0 {{.UnitPct}}
FORMULA
(L + R) / 2
LIMITS
W {{printf "%.0f" .WarningPercent}}{{.UnitPct}} • C {{printf "%.0f" .CriticalPercent}}{{.UnitPct}}

{{.LeftLabel}}

NORMAL
0.0
{{.UnitPct}}
0.0 {{.UnitForce}}
0
W {{printf "%.0f" .WarningPercent}}
C {{printf "%.0f" .CriticalPercent}}
{{printf "%.0f" .GaugeMaxPercent}}

{{.RightLabel}}

NORMAL
0.0
{{.UnitPct}}
0.0 {{.UnitForce}}
0
W {{printf "%.0f" .WarningPercent}}
C {{printf "%.0f" .CriticalPercent}}
{{printf "%.0f" .GaugeMaxPercent}}

{{.TrendMinutes}}-Minute Live Trend

Chart = percent only • FIFO • {{.MaxHistoryPoints}} points max • {{.PollMs}} ms poll
`