package main import ( "bufio" "context" "encoding/json" "fmt" "log" "net" "strings" "sync" "sync/atomic" "syscall" "time" "marte2/common/udpsprotocol" "marte2/common/wshub" ) // --------------------------------------------------------------------------- // Signal metadata (populated by DISCOVER) // --------------------------------------------------------------------------- type SignalMeta struct { Name string `json:"name"` ID uint32 `json:"id"` Type string `json:"type"` Dimensions uint8 `json:"dimensions"` Elements uint32 `json:"elements"` Names []string // canonical + alias names mapping to this ID } // --------------------------------------------------------------------------- // Outbound broadcast helpers // --------------------------------------------------------------------------- func broadcastHub(hub *wshub.Hub, v any) { b, err := json.Marshal(v) if err != nil { return } hub.Broadcast(b) } // --------------------------------------------------------------------------- // MarteController // --------------------------------------------------------------------------- type MarteController struct { hub *wshub.Hub mu sync.Mutex tcpConn net.Conn writer *bufio.Writer cmdMu sync.Mutex // serialise TCP writes sigMu sync.RWMutex signals map[uint32]*SignalMeta // id -> meta tracedMu sync.RWMutex tracedNames map[string]bool // user-visible names currently being traced // Persistent forced-signal state: replayed to new browser clients so the // forced-signals panel stays correct across page reloads. forcedMu sync.RWMutex forcedState map[string]string // signal key (may include [i]) → forced value baseTsSet bool basesMu sync.Mutex connected int32 // atomic bool lastWriteMs int64 // atomic; updated on every TCP write, used by keepalive stopCh chan struct{} // accumulates signals across DISCOVER_PART chunks; merged on final DISCOVER discoverAcc []discoverSignalJSON // cached last-known ports (updated by SERVICE_INFO) host string cmdPort int udpPort int logPort int } // NewMarteController creates a MarteController bound to the given hub. func NewMarteController(hub *wshub.Hub) *MarteController { mc := &MarteController{ hub: hub, signals: make(map[uint32]*SignalMeta), tracedNames: make(map[string]bool), forcedState: make(map[string]string), stopCh: make(chan struct{}), } // Register the new-client hook so connection + forced/traced state is // replayed to any browser that connects (or reconnects) while the server // already holds a live MARTe2 TCP session. hub.SetOnClientConnect(mc.replayStateToClient) return mc } func (m *MarteController) IsConnected() bool { return atomic.LoadInt32(&m.connected) == 1 } // replayStateToClient is called by the hub whenever a new WebSocket client // connects. It sends the current MARTe2 connection status and any persistent // forced/traced signal state so the browser UI is always consistent. func (m *MarteController) replayStateToClient(send func([]byte)) { if !m.IsConnected() { return } // Re-send "connected" so the browser updates its UI state. if b, err := json.Marshal(map[string]any{"type": "connected"}); err == nil { send(b) } // Replay forced signals. m.forcedMu.RLock() forced := make(map[string]string, len(m.forcedState)) for k, v := range m.forcedState { forced[k] = v } m.forcedMu.RUnlock() if len(forced) > 0 { if b, err := json.Marshal(map[string]any{"type": "forced_state", "signals": forced}); err == nil { send(b) } } // Replay traced signal names. m.tracedMu.RLock() traced := make([]string, 0, len(m.tracedNames)) for n := range m.tracedNames { traced = append(traced, n) } m.tracedMu.RUnlock() if len(traced) > 0 { if b, err := json.Marshal(map[string]any{"type": "traced_state", "signals": traced}); err == nil { send(b) } } } // --------------------------------------------------------------------------- // Connect / Disconnect // --------------------------------------------------------------------------- func (m *MarteController) Connect(host string, cmdPort, udpPort, logPort int) { m.Disconnect() m.mu.Lock() m.host = host m.cmdPort = cmdPort m.udpPort = udpPort m.logPort = logPort m.stopCh = make(chan struct{}) m.mu.Unlock() // Update source state so the browser shows "connecting". m.hub.SetSourceState("debug", "connecting") broadcastHub(m.hub, map[string]any{ "type": "log", "time": time.Now().Format("15:04:05.000"), "level": "INFO", "message": fmt.Sprintf("Connecting to %s cmd=%d udp=%d log=%d", host, cmdPort, udpPort, logPort), }) go m.runTCP(host, cmdPort) go m.runDebugUDP(host, udpPort) go m.runLog(host, logPort) } func (m *MarteController) Disconnect() { m.mu.Lock() select { case <-m.stopCh: // already closed default: close(m.stopCh) } if m.tcpConn != nil { m.tcpConn.Close() m.tcpConn = nil } m.mu.Unlock() atomic.StoreInt32(&m.connected, 0) m.basesMu.Lock() m.baseTsSet = false m.basesMu.Unlock() m.discoverAcc = nil m.hub.SetSourceState("debug", "disconnected") } func (m *MarteController) stopped() bool { select { case <-m.stopCh: return true default: return false } } // --------------------------------------------------------------------------- // HandleBrowserCommand — dispatch JSON commands from browser // --------------------------------------------------------------------------- func (m *MarteController) HandleBrowserCommand(msg []byte) { var env map[string]interface{} if err := json.Unmarshal(msg, &env); err != nil { return } t, _ := env["type"].(string) switch t { case "connect": data, _ := env["data"].(map[string]interface{}) if data == nil { return } host, _ := data["host"].(string) portF, _ := data["port"].(float64) udpPortF, _ := data["udp_port"].(float64) logPortF, _ := data["log_port"].(float64) if host == "" { host = "127.0.0.1" } port := int(portF) if port == 0 { port = 8080 } udpPort := int(udpPortF) if udpPort == 0 { udpPort = port + 1 } logPort := int(logPortF) if logPort == 0 { logPort = port + 2 } m.Connect(host, port, udpPort, logPort) case "disconnect": m.Disconnect() case "cmd": data, _ := env["data"].(map[string]interface{}) if data == nil { return } cmd, _ := data["cmd"].(string) if cmd != "" { m.trackForcedCmd(cmd) m.SendCommand(cmd) } } } // --------------------------------------------------------------------------- // TCP command channel // --------------------------------------------------------------------------- func (m *MarteController) runTCP(host string, port int) { addr := fmt.Sprintf("%s:%d", host, port) for !m.stopped() { conn, err := net.DialTimeout("tcp", addr, 5*time.Second) if err != nil { broadcastHub(m.hub, map[string]any{ "type": "log", "time": time.Now().Format("15:04:05.000"), "level": "WARNING", "message": fmt.Sprintf("TCP %s: %v — retrying…", addr, err), }) time.Sleep(2 * time.Second) continue } conn.(*net.TCPConn).SetNoDelay(true) m.mu.Lock() m.tcpConn = conn m.writer = bufio.NewWriter(conn) m.mu.Unlock() atomic.StoreInt32(&m.connected, 1) broadcastHub(m.hub, map[string]any{"type": "connected"}) // Send SERVICE_INFO to auto-discover ports m.writeCmd("SERVICE_INFO") go m.runKeepalive() m.readLoop(conn) atomic.StoreInt32(&m.connected, 0) broadcastHub(m.hub, map[string]any{"type": "disconnected"}) m.mu.Lock() m.tcpConn = nil m.writer = nil m.mu.Unlock() if !m.stopped() { time.Sleep(2 * time.Second) } } } func (m *MarteController) writeCmd(cmd string) { m.cmdMu.Lock() defer m.cmdMu.Unlock() m.mu.Lock() w := m.writer m.mu.Unlock() if w == nil { return } // Suppress high-frequency polling commands from both terminal and browser logs. silent := cmd == "STEP_STATUS" || cmd == "INFO" if !silent { log.Printf("[→MARTe] %s", cmd) broadcastHub(m.hub, map[string]any{ "type": "log", "time": time.Now().Format("15:04:05.000"), "level": "CMD", "message": fmt.Sprintf("→ %s", cmd), }) } w.WriteString(cmd + "\n") w.Flush() atomic.StoreInt64(&m.lastWriteMs, time.Now().UnixMilli()) } // runKeepalive sends INFO every 20 s when idle. func (m *MarteController) runKeepalive() { ticker := time.NewTicker(20 * time.Second) defer ticker.Stop() for { select { case <-m.stopCh: return case <-ticker.C: if !m.IsConnected() { continue } idleMs := time.Now().UnixMilli() - atomic.LoadInt64(&m.lastWriteMs) if idleMs >= 20_000 { m.writeCmd("INFO") } } } } // trackForcedCmd intercepts FORCE/UNFORCE commands to maintain the persistent // forced-signal state that is replayed to new browser clients. func (m *MarteController) trackForcedCmd(cmd string) { parts := strings.Fields(cmd) if len(parts) < 2 { return } switch strings.ToUpper(parts[0]) { case "FORCE": if len(parts) >= 3 { key := parts[1] val := strings.Join(parts[2:], " ") m.forcedMu.Lock() m.forcedState[key] = val m.forcedMu.Unlock() } case "UNFORCE": key := parts[1] m.forcedMu.Lock() // Remove the exact key and any element keys that share the same base name // (e.g., UNFORCE Foo removes Foo, Foo[0], Foo[1], …). delete(m.forcedState, key) prefix := key + "[" for k := range m.forcedState { if strings.HasPrefix(k, prefix) { delete(m.forcedState, k) } } m.forcedMu.Unlock() } } func (m *MarteController) SendCommand(cmd string) { // Track TRACE enable/disable so translateSignalNames can pick the right alias. if strings.HasPrefix(cmd, "TRACE ") { parts := strings.Fields(cmd) if len(parts) == 3 { name := parts[1] enable := parts[2] == "1" m.tracedMu.Lock() if enable { m.tracedNames[name] = true } else { delete(m.tracedNames, name) } m.tracedMu.Unlock() } } m.writeCmd(cmd) } func (m *MarteController) readLoop(conn net.Conn) { scanner := bufio.NewScanner(conn) scanner.Buffer(make([]byte, 8*1024*1024), 8*1024*1024) var jsonAcc strings.Builder inJSON := false for scanner.Scan() { if m.stopped() { return } line := scanner.Text() trimmed := strings.TrimSpace(line) if trimmed == "" { continue } // Detect start of JSON block if !inJSON && strings.HasPrefix(trimmed, "{") { inJSON = true jsonAcc.Reset() } if inJSON { jsonAcc.WriteString(trimmed) tag, done := detectJSONDone(trimmed) if done { inJSON = false raw := jsonAcc.String() // Strip trailing sentinel idx := strings.Index(raw, "OK "+tag) if idx >= 0 { raw = strings.TrimSpace(raw[:idx]) } m.handleJSONResponse(tag, raw) jsonAcc.Reset() } } else { m.handleTextLine(trimmed) } } } func detectJSONDone(line string) (string, bool) { tags := []string{ "DISCOVER_PART", "DISCOVER", "TREE", "INFO", "CONFIG", "STEP_STATUS", "VALUE", "MSG", "TRACE", "FORCE", "UNFORCE", "BREAK", "PAUSE", "RESUME", "STEP", "MONITOR", "UNMONITOR", "LS", } for _, t := range tags { if line == "OK "+t { return t, true } } return "", false } func (m *MarteController) handleJSONResponse(tag, data string) { silent := tag == "STEP_STATUS" || tag == "INFO" if !silent { log.Printf("[←MARTe] %s %d bytes", tag, len(data)) broadcastHub(m.hub, map[string]any{ "type": "log", "time": time.Now().Format("15:04:05.000"), "level": "RESP", "message": fmt.Sprintf("← %s (%d B)", tag, len(data)), }) } switch tag { case "DISCOVER_PART": var resp discoverResp if err := json.Unmarshal([]byte(data), &resp); err != nil { log.Printf("[DISCOVER_PART] parse error: %v", err) return } m.discoverAcc = append(m.discoverAcc, resp.Signals...) log.Printf("[DISCOVER_PART] accumulated %d signals (total so far: %d)", len(resp.Signals), len(m.discoverAcc)) return case "DISCOVER": var resp discoverResp if err := json.Unmarshal([]byte(data), &resp); err != nil { log.Printf("[DISCOVER] parse error: %v", err) return } all := append(m.discoverAcc, resp.Signals...) m.discoverAcc = nil m.parseDiscoverSignals(all) // Synthesize SignalInfo for the hub so the plot system gets a config m.synthesizeHubConfig(all) // Re-marshal the merged list so the browser gets a single consistent blob. merged, _ := json.Marshal(discoverResp{Signals: all}) broadcastHub(m.hub, map[string]any{ "type": "response", "tag": "DISCOVER", "data": string(merged), }) return case "TREE": broadcastHub(m.hub, map[string]any{ "type": "tree_node", "data": data, }) return } broadcastHub(m.hub, map[string]any{ "type": "response", "tag": tag, "data": data, }) } func (m *MarteController) handleTextLine(line string) { if strings.HasPrefix(line, "OK SERVICE_INFO") { parts := strings.Fields(line) newUDP, newLog := 0, 0 for _, p := range parts { if strings.HasPrefix(p, "UDP_STREAM:") { fmt.Sscanf(p[11:], "%d", &newUDP) } if strings.HasPrefix(p, "TCP_LOG:") { fmt.Sscanf(p[8:], "%d", &newLog) } } broadcastHub(m.hub, map[string]any{ "type": "response", "tag": "SERVICE_INFO", "data": line[len("OK SERVICE_INFO "):], }) if newUDP > 0 || newLog > 0 { broadcastHub(m.hub, map[string]any{ "type": "service_config", "udp_port": newUDP, "log_port": newLog, }) m.mu.Lock() host := m.host oldUDP := m.udpPort oldLog := m.logPort if newUDP > 0 { m.udpPort = newUDP } if newLog > 0 { m.logPort = newLog } m.mu.Unlock() if newUDP > 0 && newUDP != oldUDP { go m.runDebugUDP(host, newUDP) } if newLog > 0 && newLog != oldLog { go m.runLog(host, newLog) } } } broadcastHub(m.hub, map[string]any{ "type": "text_line", "data": line, }) } // --------------------------------------------------------------------------- // DISCOVER parsing // --------------------------------------------------------------------------- type discoverSignalJSON struct { Name string `json:"name"` ID uint32 `json:"id"` Type string `json:"type"` Dimensions uint8 `json:"dimensions"` Elements uint32 `json:"elements"` } type discoverResp struct { Signals []discoverSignalJSON `json:"Signals"` } func (m *MarteController) parseDiscoverSignals(sigs []discoverSignalJSON) { m.sigMu.Lock() defer m.sigMu.Unlock() m.signals = make(map[uint32]*SignalMeta, len(sigs)) for _, s := range sigs { el := s.Elements if el == 0 { el = 1 } if existing, ok := m.signals[s.ID]; ok { existing.Names = append(existing.Names, s.Name) continue } meta := &SignalMeta{ Name: s.Name, ID: s.ID, Type: s.Type, Dimensions: s.Dimensions, Elements: el, Names: []string{s.Name}, } m.signals[s.ID] = meta } log.Printf("[DISCOVER] registered %d unique signals from %d entries", len(m.signals), len(sigs)) } // translateSignalNames maps UDPS signal names (DS canonical, e.g. "DDB1.Sine1") // to the preferred GAM-path alias (e.g. "App.Functions.SineGAM1.Out.Sine1"). // // DebugService always sets signals[i]->name to the first registered name, which // is the DataSource canonical path. The user-facing name in the tree and the // tracedSet is the GAM alias (contains ".Out." or ".In."). Without this // translation the buffer key created from UDPS CONFIG ("debug:DDB1.Sine1") // never matches the buffer key the tracedTab looks up ("debug:App…Out.Sine1"). func (m *MarteController) translateSignalNames(sigs []udpsprotocol.SignalInfo) []udpsprotocol.SignalInfo { m.sigMu.RLock() defer m.sigMu.RUnlock() if len(m.signals) == 0 { return sigs // no DISCOVER data yet — return as-is } // Build a reverse map: every known alias name → SignalMeta nameToMeta := make(map[string]*SignalMeta, len(m.signals)*2) for _, meta := range m.signals { for _, n := range meta.Names { nameToMeta[n] = meta } } // Snapshot the user's currently-traced names for matching. m.tracedMu.RLock() traced := make(map[string]bool, len(m.tracedNames)) for k, v := range m.tracedNames { traced[k] = v } m.tracedMu.RUnlock() result := make([]udpsprotocol.SignalInfo, len(sigs)) for i, sig := range sigs { result[i] = sig meta, ok := nameToMeta[sig.Name] if !ok { continue } best := sig.Name // Priority 1: find a traced name that alias-matches this signal (mirrors // C++ AliasMatch: exact or suffix in either direction, dot-boundary). outer: for _, n := range meta.Names { for tracedName := range traced { if aliasMatch(n, tracedName) { best = tracedName // use the name the user sees in the tree break outer } } } // Priority 2 (fallback): longest alias with ".Out." or ".In." (GAM path). if best == sig.Name { for _, n := range meta.Names { if (strings.Contains(n, ".Out.") || strings.Contains(n, ".In.")) && len(n) > len(best) { best = n } } } if best != sig.Name { log.Printf("[debug-udp] rename %q → %q", sig.Name, best) result[i].Name = best } } return result } // aliasMatch mirrors C++ AliasMatch: returns true if a and b are equal or one // is a dot-boundary suffix of the other. func aliasMatch(a, b string) bool { if a == b { return true } return suffixMatchDot(a, b) || suffixMatchDot(b, a) } func suffixMatchDot(str, suffix string) bool { if len(suffix) > len(str) { return false } tail := str[len(str)-len(suffix):] if tail != suffix { return false } return len(str) == len(suffix) || str[len(str)-len(suffix)-1] == '.' } // typeCodeFromString maps a MARTe2 type string to a UDPS type code. func typeCodeFromString(t string) uint8 { switch strings.ToLower(t) { case "uint8": return 0 case "int8": return 1 case "uint16": return 2 case "int16": return 3 case "uint32": return 4 case "int32": return 5 case "uint64": return 6 case "int64": return 7 case "float32": return 8 case "float64": return 9 default: return 4 // default to uint32 } } // synthesizeHubConfig converts DISCOVER signals to udpsprotocol.SignalInfo and // sends them to the hub so the signal list panel is populated before UDP data arrives. func (m *MarteController) synthesizeHubConfig(sigs []discoverSignalJSON) { seen := make(map[uint32]bool) var sigInfos []udpsprotocol.SignalInfo for _, s := range sigs { if seen[s.ID] { continue } seen[s.ID] = true el := s.Elements if el == 0 { el = 1 } numRows := el numCols := uint32(1) if s.Dimensions >= 2 { numCols = el numRows = 1 } si := udpsprotocol.SignalInfo{ Name: s.Name, TypeCode: typeCodeFromString(s.Type), QuantType: 0, NumDimensions: s.Dimensions, NumRows: numRows, NumCols: numCols, RangeMin: 0, RangeMax: 0, TimeMode: udpsprotocol.TimeModePacket, SamplingRate: 0, TimeSignalIdx: udpsprotocol.NoTimeSignal, Unit: "", } sigInfos = append(sigInfos, si) } m.hub.UpdateConfigForSource("debug", sigInfos) } // --------------------------------------------------------------------------- // Debug UDP receiver — receives UDPS packets from DebugService // --------------------------------------------------------------------------- func (m *MarteController) runDebugUDP(host string, port int) { addr := fmt.Sprintf("0.0.0.0:%d", port) // Use SO_REUSEPORT so we can bind the same port that DebugService already // holds open. Without this the second bind fails with EADDRINUSE and we // receive nothing. lc := net.ListenConfig{ Control: func(network, address string, c syscall.RawConn) error { return c.Control(func(fd uintptr) { const SO_REUSEPORT = 0xf // Linux SO_REUSEPORT = 15 if err := syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, SO_REUSEPORT, 1); err != nil { log.Printf("[debug-udp] SO_REUSEPORT: %v", err) } }) }, } pc, err := lc.ListenPacket(context.Background(), "udp4", addr) if err != nil { msg := fmt.Sprintf("UDP bind on %s failed: %v — rebuild DebugService C++ and restart", addr, err) log.Printf("[debug-udp] %s", msg) broadcastHub(m.hub, map[string]any{ "type": "log", "time": time.Now().Format("15:04:05.000"), "level": "ERROR", "message": msg, }) return } conn := pc.(*net.UDPConn) defer conn.Close() conn.SetReadBuffer(10 * 1024 * 1024) log.Printf("[debug-udp] listening on %s for UDPS packets", addr) broadcastHub(m.hub, map[string]any{ "type": "log", "time": time.Now().Format("15:04:05.000"), "level": "INFO", "message": fmt.Sprintf("UDP listener bound on %s", addr), }) reassembler := udpsprotocol.NewReassembler(2 * time.Second) buf := make([]byte, 65535) var currentSigs []udpsprotocol.SignalInfo var currentPublishMode uint8 var pktCount int64 for !m.stopped() { conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) n, _, err := conn.ReadFromUDP(buf) arrivalTime := time.Now() if err != nil { continue } pktCount++ if n < udpsprotocol.HeaderSize { continue } hdr, err := udpsprotocol.ParseHeader(buf[:n]) if err != nil { continue } payload := make([]byte, n-udpsprotocol.HeaderSize) copy(payload, buf[udpsprotocol.HeaderSize:n]) complete, ok := reassembler.AddFragment(hdr, payload) if !ok { continue } switch hdr.Type { case udpsprotocol.PktConfig: sigs, pm, err := udpsprotocol.ParseConfig(complete) if err != nil { log.Printf("[debug-udp] parse config: %v", err) continue } sigs = m.translateSignalNames(sigs) currentSigs = sigs currentPublishMode = pm m.hub.UpdateConfigForSource("debug", sigs) m.hub.SetSourceState("debug", "connected") case udpsprotocol.PktData: if len(currentSigs) == 0 { continue } samples, err := udpsprotocol.ParseData(complete, currentSigs, currentPublishMode, arrivalTime) if err != nil { log.Printf("[debug-udp] parse data: %v", err) continue } for _, s := range samples { m.hub.PushDataForSource("debug", s) } } } log.Printf("[debug-udp] stopped") } // --------------------------------------------------------------------------- // TCP log channel // --------------------------------------------------------------------------- func (m *MarteController) runLog(host string, port int) { addr := fmt.Sprintf("%s:%d", host, port) for !m.stopped() { conn, err := net.DialTimeout("tcp", addr, 5*time.Second) if err != nil { time.Sleep(2 * time.Second) continue } scanner := bufio.NewScanner(conn) for scanner.Scan() { if m.stopped() { conn.Close() return } line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "LOG ") { rest := line[4:] idx := strings.Index(rest, " ") if idx < 0 { continue } level := rest[:idx] msg := rest[idx+1:] broadcastHub(m.hub, map[string]any{ "type": "log", "time": time.Now().Format("15:04:05.000"), "level": level, "message": msg, }) } } conn.Close() if !m.stopped() { time.Sleep(2 * time.Second) } } }