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.Subtitle) + `
MAX_TONNAGE = ` + fmt.Sprintf("%.1f", cfg.Press.MAX_TONNAGE) + ` ` + esc(cfg.UI.UnitForce) + `