853 lines
31 KiB
Go
853 lines
31 KiB
Go
|
|
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 := `<!DOCTYPE html>
|
|||
|
|
<html lang="en">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>` + esc(cfg.UI.Title) + `</title>
|
|||
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
|||
|
|
<style>
|
|||
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap');
|
|||
|
|
body { font-family: 'Inter', system-ui, sans-serif; }
|
|||
|
|
.title { font-family: 'Space Grotesk', sans-serif; }
|
|||
|
|
.glass { background: rgba(255,255,255,0.06); backdrop-filter: blur(12px); }
|
|||
|
|
.gauge-container { position: relative; width: 320px; height: 320px; margin: 0 auto; }
|
|||
|
|
.soft-glow-green { box-shadow: 0 0 0 1px rgba(34,197,94,0.35), 0 0 30px rgba(34,197,94,0.08); }
|
|||
|
|
.soft-glow-yellow { box-shadow: 0 0 0 1px rgba(234,179,8,0.35), 0 0 30px rgba(234,179,8,0.08); }
|
|||
|
|
.soft-glow-red { box-shadow: 0 0 0 1px rgba(239,68,68,0.35), 0 0 30px rgba(239,68,68,0.08); }
|
|||
|
|
.metric-big { line-height: 0.95; }
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body class="bg-zinc-950 text-zinc-100">
|
|||
|
|
<div class="max-w-7xl mx-auto p-8 min-h-screen">
|
|||
|
|
<div id="alarm-banner" class="hidden mb-8 bg-red-600/90 border border-red-500 text-white px-8 py-4 rounded-2xl flex items-center justify-between text-lg font-medium">
|
|||
|
|
<div class="flex items-center gap-3">
|
|||
|
|
<span class="text-2xl">⚠️</span>
|
|||
|
|
<span id="alarm-text">CRITICAL FORCE ALARM ACTIVE</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between mb-10">
|
|||
|
|
<div>
|
|||
|
|
<h1 class="title text-5xl xl:text-6xl font-semibold tracking-tighter bg-gradient-to-r from-sky-300 to-violet-300 bg-clip-text text-transparent">
|
|||
|
|
` + esc(cfg.UI.Title) + `
|
|||
|
|
</h1>
|
|||
|
|
<p class="text-zinc-400 mt-2 text-lg">` + esc(cfg.UI.Subtitle) + `</p>
|
|||
|
|
<p class="text-zinc-500 mt-1 text-sm font-mono">MAX_TONNAGE = ` + fmt.Sprintf("%.1f", cfg.Press.MAX_TONNAGE) + ` ` + esc(cfg.UI.UnitForce) + `</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="glass border border-zinc-700 px-8 py-4 rounded-3xl flex items-center gap-8 w-fit">
|
|||
|
|
<div class="flex items-center gap-3">
|
|||
|
|
<div id="dot" class="w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20"></div>
|
|||
|
|
<span id="status-text" class="font-semibold text-lg text-red-400">Disconnected</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="h-8 w-px bg-zinc-700"></div>
|
|||
|
|
<div id="last-update" class="font-mono text-zinc-400 text-sm">Last update: --:--:--.---</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="glass border border-zinc-700 rounded-3xl p-8 mb-12">
|
|||
|
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
|||
|
|
<div>
|
|||
|
|
<div class="text-zinc-400 text-sm uppercase tracking-[0.25em]">TOTAL FORCE</div>
|
|||
|
|
<div class="mt-2 flex items-end gap-4">
|
|||
|
|
<div class="text-6xl font-mono font-bold text-emerald-300 metric-big" id="sum-kn">0.0</div>
|
|||
|
|
<div class="text-2xl text-emerald-400 mb-1">` + esc(cfg.UI.UnitForce) + `</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 min-w-[280px]">
|
|||
|
|
<div class="bg-zinc-900/60 rounded-2xl px-6 py-4 border border-zinc-800">
|
|||
|
|
<div class="text-zinc-500 text-xs uppercase tracking-widest">TOTAL %</div>
|
|||
|
|
<div class="text-3xl font-mono font-bold text-sky-200 mt-1"><span id="sum-percent">0.0</span> ` + esc(cfg.UI.UnitPct) + `</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="bg-zinc-900/60 rounded-2xl px-6 py-4 border border-zinc-800">
|
|||
|
|
<div class="text-zinc-500 text-xs uppercase tracking-widest">FORMULA</div>
|
|||
|
|
<div class="text-sm font-mono text-zinc-300 mt-2">(L + R) / 2</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-12">
|
|||
|
|
<div id="card-l" class="glass border border-zinc-700 rounded-3xl p-10 transition-all duration-300">
|
|||
|
|
<div class="flex justify-between items-center mb-8 gap-6">
|
|||
|
|
<div class="flex items-center gap-4">
|
|||
|
|
<div id="led-l" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
|||
|
|
<div>
|
|||
|
|
<h2 class="text-3xl font-bold tracking-wider">` + esc(cfg.UI.LeftLabel) + `</h2>
|
|||
|
|
<div id="state-l" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div id="digital-l" class="text-right">
|
|||
|
|
<div class="percent text-6xl font-mono font-bold text-sky-100 leading-none">0.0</div>
|
|||
|
|
<div class="text-xl text-sky-400 mt-1">` + esc(cfg.UI.UnitPct) + `</div>
|
|||
|
|
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 ` + esc(cfg.UI.UnitForce) + `</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="gauge-container">
|
|||
|
|
<canvas id="gaugeL"></canvas>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="flex justify-between text-xs font-mono text-zinc-500 mt-4 px-4">
|
|||
|
|
<span>0</span>
|
|||
|
|
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.WarningPercent) + `%</span>
|
|||
|
|
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.CriticalPercent) + `%</span>
|
|||
|
|
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.GaugeMaxPercent) + `%</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="card-r" class="glass border border-zinc-700 rounded-3xl p-10 transition-all duration-300">
|
|||
|
|
<div class="flex justify-between items-center mb-8 gap-6">
|
|||
|
|
<div class="flex items-center gap-4">
|
|||
|
|
<div id="led-r" class="w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40"></div>
|
|||
|
|
<div>
|
|||
|
|
<h2 class="text-3xl font-bold tracking-wider">` + esc(cfg.UI.RightLabel) + `</h2>
|
|||
|
|
<div id="state-r" class="text-sm text-zinc-400 mt-1">NORMAL</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div id="digital-r" class="text-right">
|
|||
|
|
<div class="percent text-6xl font-mono font-bold text-violet-100 leading-none">0.0</div>
|
|||
|
|
<div class="text-xl text-violet-400 mt-1">` + esc(cfg.UI.UnitPct) + `</div>
|
|||
|
|
<div class="kn text-lg text-zinc-300 font-mono mt-3">0.0 ` + esc(cfg.UI.UnitForce) + `</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="gauge-container">
|
|||
|
|
<canvas id="gaugeR"></canvas>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="flex justify-between text-xs font-mono text-zinc-500 mt-4 px-4">
|
|||
|
|
<span>0</span>
|
|||
|
|
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.WarningPercent) + `%</span>
|
|||
|
|
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.CriticalPercent) + `%</span>
|
|||
|
|
<span>` + fmt.Sprintf("%.0f", cfg.Thresholds.GaugeMaxPercent) + `%</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mt-12 glass border border-zinc-700 rounded-3xl p-10">
|
|||
|
|
<div class="flex flex-col gap-3 md:flex-row md:justify-between md:items-center mb-6">
|
|||
|
|
<h2 class="text-3xl font-semibold">` + fmt.Sprintf("%d", cfg.Trend.Minutes) + `-Minute Live Trend</h2>
|
|||
|
|
<div class="text-emerald-400 font-mono text-sm">Chart = percent only • FIFO • ` + fmt.Sprintf("%d", maxHistoryPoints) + ` points max • ` + fmt.Sprintf("%d", cfg.PLC.PollMs) + ` ms poll</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="h-96">
|
|||
|
|
<canvas id="lineChart"></canvas>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const WARNING_PERCENT = ` + fmt.Sprintf("%.1f", cfg.Thresholds.WarningPercent) + `;
|
|||
|
|
const CRITICAL_PERCENT = ` + fmt.Sprintf("%.1f", cfg.Thresholds.CriticalPercent) + `;
|
|||
|
|
const GAUGE_MAX_PERCENT = ` + fmt.Sprintf("%.1f", cfg.Thresholds.GaugeMaxPercent) + `;
|
|||
|
|
|
|||
|
|
let gaugeL, gaugeR, lineChart;
|
|||
|
|
|
|||
|
|
function createGauge(ctx, activeColor) {
|
|||
|
|
return new Chart(ctx, {
|
|||
|
|
type: 'doughnut',
|
|||
|
|
data: {
|
|||
|
|
datasets: [{
|
|||
|
|
data: [0, GAUGE_MAX_PERCENT],
|
|||
|
|
backgroundColor: [activeColor, '#27272a'],
|
|||
|
|
borderWidth: 0,
|
|||
|
|
circumference: 270,
|
|||
|
|
rotation: 225,
|
|||
|
|
cutout: '74%'
|
|||
|
|
}]
|
|||
|
|
},
|
|||
|
|
options: {
|
|||
|
|
responsive: true,
|
|||
|
|
maintainAspectRatio: false,
|
|||
|
|
plugins: {
|
|||
|
|
legend: { display: false },
|
|||
|
|
tooltip: { enabled: false }
|
|||
|
|
},
|
|||
|
|
animation: {
|
|||
|
|
duration: 250
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setGaugeValue(gauge, percentValue, color) {
|
|||
|
|
const v = Math.max(0, Math.min(GAUGE_MAX_PERCENT, Number(percentValue) || 0));
|
|||
|
|
gauge.data.datasets[0].data = [v, GAUGE_MAX_PERCENT - v];
|
|||
|
|
gauge.data.datasets[0].backgroundColor = [color, '#27272a'];
|
|||
|
|
gauge.update('none');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setStatusConnected(connected) {
|
|||
|
|
const dot = document.getElementById('dot');
|
|||
|
|
const text = document.getElementById('status-text');
|
|||
|
|
|
|||
|
|
if (connected) {
|
|||
|
|
dot.className = 'w-4 h-4 rounded-full bg-emerald-400 ring-4 ring-emerald-400/20';
|
|||
|
|
text.textContent = 'Connected';
|
|||
|
|
text.className = 'font-semibold text-lg text-emerald-400';
|
|||
|
|
} else {
|
|||
|
|
dot.className = 'w-4 h-4 rounded-full bg-red-500 ring-4 ring-red-500/20';
|
|||
|
|
text.textContent = 'Disconnected';
|
|||
|
|
text.className = 'font-semibold text-lg text-red-400';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getZone(percentValue) {
|
|||
|
|
if (percentValue >= CRITICAL_PERCENT) return 'critical';
|
|||
|
|
if (percentValue >= WARNING_PERCENT) return 'warning';
|
|||
|
|
return 'normal';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function zoneColor(zone, left = true) {
|
|||
|
|
if (zone === 'critical') return '#ef4444';
|
|||
|
|
if (zone === 'warning') return '#eab308';
|
|||
|
|
return left ? '#22d3ee' : '#c084fc';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyChannelState(side, percentValue) {
|
|||
|
|
const zone = getZone(percentValue);
|
|||
|
|
const card = document.getElementById('card-' + side);
|
|||
|
|
const led = document.getElementById('led-' + side);
|
|||
|
|
const stateText = document.getElementById('state-' + side);
|
|||
|
|
|
|||
|
|
card.classList.remove('soft-glow-green', 'soft-glow-yellow', 'soft-glow-red');
|
|||
|
|
|
|||
|
|
if (zone === 'critical') {
|
|||
|
|
led.className = 'w-6 h-6 bg-red-500 rounded-full shadow-lg shadow-red-600/50';
|
|||
|
|
stateText.textContent = 'CRITICAL';
|
|||
|
|
stateText.className = 'text-sm text-red-400 mt-1 font-semibold';
|
|||
|
|
card.classList.add('soft-glow-red');
|
|||
|
|
} else if (zone === 'warning') {
|
|||
|
|
led.className = 'w-6 h-6 bg-yellow-400 rounded-full shadow-lg shadow-yellow-500/50';
|
|||
|
|
stateText.textContent = 'WARNING';
|
|||
|
|
stateText.className = 'text-sm text-yellow-400 mt-1 font-semibold';
|
|||
|
|
card.classList.add('soft-glow-yellow');
|
|||
|
|
} else {
|
|||
|
|
led.className = 'w-6 h-6 bg-emerald-500 rounded-full shadow-lg shadow-emerald-600/40';
|
|||
|
|
stateText.textContent = 'NORMAL';
|
|||
|
|
stateText.className = 'text-sm text-emerald-400 mt-1 font-semibold';
|
|||
|
|
card.classList.add('soft-glow-green');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatLastUpdate(isoString) {
|
|||
|
|
if (!isoString) return 'Last update: --:--:--.---';
|
|||
|
|
const d = new Date(isoString);
|
|||
|
|
if (isNaN(d.getTime())) return 'Last update: --:--:--.---';
|
|||
|
|
|
|||
|
|
const hh = String(d.getHours()).padStart(2, '0');
|
|||
|
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
|||
|
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
|||
|
|
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
|||
|
|
|
|||
|
|
return 'Last update: ' + hh + ':' + mm + ':' + ss + '.' + ms;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateAlarmBanner(leftPercent, rightPercent) {
|
|||
|
|
const banner = document.getElementById('alarm-banner');
|
|||
|
|
const text = document.getElementById('alarm-text');
|
|||
|
|
|
|||
|
|
const leftCritical = leftPercent >= CRITICAL_PERCENT;
|
|||
|
|
const rightCritical = rightPercent >= CRITICAL_PERCENT;
|
|||
|
|
|
|||
|
|
if (leftCritical || rightCritical) {
|
|||
|
|
if (leftCritical && rightCritical) {
|
|||
|
|
text.textContent = 'CRITICAL FORCE ALARM ACTIVE • LEFT + RIGHT';
|
|||
|
|
} else if (leftCritical) {
|
|||
|
|
text.textContent = 'CRITICAL FORCE ALARM ACTIVE • LEFT';
|
|||
|
|
} else {
|
|||
|
|
text.textContent = 'CRITICAL FORCE ALARM ACTIVE • RIGHT';
|
|||
|
|
}
|
|||
|
|
banner.classList.remove('hidden');
|
|||
|
|
} else {
|
|||
|
|
banner.classList.add('hidden');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function fetchData() {
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/data', { cache: 'no-store' });
|
|||
|
|
const d = await res.json();
|
|||
|
|
|
|||
|
|
const leftPercent = Number(d.sila_l) || 0;
|
|||
|
|
const rightPercent = Number(d.sila_r) || 0;
|
|||
|
|
const leftKN = Number(d.sila_l_kn) || 0;
|
|||
|
|
const rightKN = Number(d.sila_r_kn) || 0;
|
|||
|
|
const sumPercent = Number(d.sum_percent) || 0;
|
|||
|
|
const sumKN = Number(d.sum_kn) || 0;
|
|||
|
|
|
|||
|
|
setStatusConnected(!!d.connected);
|
|||
|
|
|
|||
|
|
document.querySelector('#digital-l .percent').textContent = leftPercent.toFixed(1);
|
|||
|
|
document.querySelector('#digital-l .kn').textContent = leftKN.toFixed(1) + ' ` + esc(cfg.UI.UnitForce) + `';
|
|||
|
|
|
|||
|
|
document.querySelector('#digital-r .percent').textContent = rightPercent.toFixed(1);
|
|||
|
|
document.querySelector('#digital-r .kn').textContent = rightKN.toFixed(1) + ' ` + esc(cfg.UI.UnitForce) + `';
|
|||
|
|
|
|||
|
|
document.getElementById('sum-percent').textContent = sumPercent.toFixed(1);
|
|||
|
|
document.getElementById('sum-kn').textContent = sumKN.toFixed(1);
|
|||
|
|
|
|||
|
|
applyChannelState('l', leftPercent);
|
|||
|
|
applyChannelState('r', rightPercent);
|
|||
|
|
|
|||
|
|
setGaugeValue(gaugeL, leftPercent, zoneColor(getZone(leftPercent), true));
|
|||
|
|
setGaugeValue(gaugeR, rightPercent, zoneColor(getZone(rightPercent), false));
|
|||
|
|
|
|||
|
|
updateAlarmBanner(leftPercent, rightPercent);
|
|||
|
|
|
|||
|
|
document.getElementById('last-update').textContent = formatLastUpdate(d.last_update);
|
|||
|
|
|
|||
|
|
if (Array.isArray(d.history) && d.history.length > 0) {
|
|||
|
|
const labels = d.history.map(h => h.time);
|
|||
|
|
const dataL = d.history.map(h => h.sila_l);
|
|||
|
|
const dataR = d.history.map(h => h.sila_r);
|
|||
|
|
const warningLine = new Array(d.history.length).fill(WARNING_PERCENT);
|
|||
|
|
const criticalLine = new Array(d.history.length).fill(CRITICAL_PERCENT);
|
|||
|
|
|
|||
|
|
lineChart.data.labels = labels;
|
|||
|
|
lineChart.data.datasets[0].data = dataL;
|
|||
|
|
lineChart.data.datasets[1].data = dataR;
|
|||
|
|
lineChart.data.datasets[2].data = warningLine;
|
|||
|
|
lineChart.data.datasets[3].data = criticalLine;
|
|||
|
|
lineChart.update('none');
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn('Fetch error:', err);
|
|||
|
|
setStatusConnected(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.onload = () => {
|
|||
|
|
gaugeL = createGauge(document.getElementById('gaugeL'), '#22d3ee');
|
|||
|
|
gaugeR = createGauge(document.getElementById('gaugeR'), '#c084fc');
|
|||
|
|
|
|||
|
|
lineChart = new Chart(document.getElementById('lineChart'), {
|
|||
|
|
type: 'line',
|
|||
|
|
data: {
|
|||
|
|
labels: [],
|
|||
|
|
datasets: [
|
|||
|
|
{
|
|||
|
|
label: 'Sila L %',
|
|||
|
|
borderColor: '#22d3ee',
|
|||
|
|
backgroundColor: 'rgba(34,211,238,0.10)',
|
|||
|
|
borderWidth: 3,
|
|||
|
|
tension: 0.25,
|
|||
|
|
pointRadius: 0,
|
|||
|
|
data: []
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: 'Sila R %',
|
|||
|
|
borderColor: '#c084fc',
|
|||
|
|
backgroundColor: 'rgba(192,132,252,0.10)',
|
|||
|
|
borderWidth: 3,
|
|||
|
|
tension: 0.25,
|
|||
|
|
pointRadius: 0,
|
|||
|
|
data: []
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: 'Warning %',
|
|||
|
|
borderColor: '#eab308',
|
|||
|
|
borderWidth: 1.5,
|
|||
|
|
borderDash: [8, 8],
|
|||
|
|
pointRadius: 0,
|
|||
|
|
data: []
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
label: 'Critical %',
|
|||
|
|
borderColor: '#ef4444',
|
|||
|
|
borderWidth: 1.5,
|
|||
|
|
borderDash: [8, 8],
|
|||
|
|
pointRadius: 0,
|
|||
|
|
data: []
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
options: {
|
|||
|
|
responsive: true,
|
|||
|
|
maintainAspectRatio: false,
|
|||
|
|
interaction: {
|
|||
|
|
mode: 'index',
|
|||
|
|
intersect: false
|
|||
|
|
},
|
|||
|
|
scales: {
|
|||
|
|
x: {
|
|||
|
|
grid: { color: '#3f3f46' },
|
|||
|
|
ticks: {
|
|||
|
|
color: '#71717a',
|
|||
|
|
maxTicksLimit: 12
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
y: {
|
|||
|
|
min: 0,
|
|||
|
|
max: GAUGE_MAX_PERCENT,
|
|||
|
|
grid: { color: '#3f3f46' },
|
|||
|
|
ticks: {
|
|||
|
|
color: '#71717a',
|
|||
|
|
stepSize: 10
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
plugins: {
|
|||
|
|
legend: {
|
|||
|
|
position: 'top',
|
|||
|
|
labels: { color: '#e4e4e7' }
|
|||
|
|
},
|
|||
|
|
tooltip: {
|
|||
|
|
backgroundColor: 'rgba(9,9,11,0.95)'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
fetchData();
|
|||
|
|
setInterval(fetchData, ` + fmt.Sprintf("%d", cfg.PLC.PollMs) + `);
|
|||
|
|
};
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>`
|
|||
|
|
|
|||
|
|
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))
|
|||
|
|
}
|