diff --git a/Tools/go_web_client/go.mod b/Tools/go_web_client/go.mod new file mode 100644 index 0000000..6a47db8 --- /dev/null +++ b/Tools/go_web_client/go.mod @@ -0,0 +1,7 @@ +module marte2-web-client + +go 1.21 + +require github.com/gorilla/websocket v1.5.1 + +require golang.org/x/net v0.17.0 // indirect diff --git a/Tools/go_web_client/go.sum b/Tools/go_web_client/go.sum new file mode 100644 index 0000000..272772f --- /dev/null +++ b/Tools/go_web_client/go.sum @@ -0,0 +1,4 @@ +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= diff --git a/Tools/go_web_client/main.go b/Tools/go_web_client/main.go new file mode 100644 index 0000000..4549f1b --- /dev/null +++ b/Tools/go_web_client/main.go @@ -0,0 +1,237 @@ +package main + +import ( + "embed" + "encoding/json" + "flag" + "io/fs" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +//go:embed static +var staticFiles embed.FS + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// --------------------------------------------------------------------------- +// Wire message types +// --------------------------------------------------------------------------- + +type InMsg struct { + Type string `json:"type"` + Data json.RawMessage `json:"data,omitempty"` +} + +type ConnectData struct { + Host string `json:"host"` + Port int `json:"port"` + UDPPort int `json:"udp_port"` + LogPort int `json:"log_port"` +} + +type CmdData struct { + Cmd string `json:"cmd"` +} + +// --------------------------------------------------------------------------- +// Hub – fan-out WS messages to all connected browsers +// --------------------------------------------------------------------------- + +type Hub struct { + mu sync.RWMutex + clients map[*WSClient]struct{} + marte *MarteClient +} + +func newHub() *Hub { + return &Hub{clients: make(map[*WSClient]struct{})} +} + +func (h *Hub) add(c *WSClient) { + h.mu.Lock() + h.clients[c] = struct{}{} + h.mu.Unlock() +} + +func (h *Hub) remove(c *WSClient) { + h.mu.Lock() + delete(h.clients, c) + h.mu.Unlock() +} + +func (h *Hub) broadcast(msg []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + for c := range h.clients { + select { + case c.send <- msg: + default: + } + } +} + +// --------------------------------------------------------------------------- +// WSClient – one browser tab +// --------------------------------------------------------------------------- + +type WSClient struct { + hub *Hub + conn *websocket.Conn + send chan []byte +} + +func (c *WSClient) writePump() { + ticker := time.NewTicker(30 * time.Second) + defer func() { + ticker.Stop() + c.conn.Close() + }() + for { + select { + case msg, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func (c *WSClient) readPump() { + defer func() { + c.hub.remove(c) + c.conn.Close() + }() + c.conn.SetReadLimit(256 * 1024) + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + for { + _, raw, err := c.conn.ReadMessage() + if err != nil { + break + } + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + + var in InMsg + if err := json.Unmarshal(raw, &in); err != nil { + continue + } + c.handleIncoming(in) + } +} + +func (c *WSClient) handleIncoming(in InMsg) { + m := c.hub.marte + switch in.Type { + case "connect": + var d ConnectData + if err := json.Unmarshal(in.Data, &d); err != nil { + return + } + if d.Host == "" { + d.Host = "127.0.0.1" + } + if d.Port == 0 { + d.Port = 8080 + } + if d.UDPPort == 0 { + d.UDPPort = 8081 + } + if d.LogPort == 0 { + d.LogPort = 8082 + } + m.Connect(d.Host, d.Port, d.UDPPort, d.LogPort) + + case "disconnect": + m.Disconnect() + + case "cmd": + var d CmdData + if err := json.Unmarshal(in.Data, &d); err != nil { + return + } + m.SendCommand(d.Cmd) + } +} + +// --------------------------------------------------------------------------- +// HTTP handler +// --------------------------------------------------------------------------- + +func wsHandler(hub *Hub, w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Println("upgrade:", err) + return + } + c := &WSClient{hub: hub, conn: conn, send: make(chan []byte, 256)} + hub.add(c) + + // Send current connection status to newly joined client. + if hub.marte.IsConnected() { + b, _ := json.Marshal(map[string]any{"type": "connected"}) + c.send <- b + // Also push current signal list if already discovered. + hub.marte.SendCachedState(c) + } + + go c.writePump() + c.readPump() // blocks until client disconnects +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +func main() { + addr := flag.String("addr", ":7777", "HTTP listen address") + flag.Parse() + + hub := newHub() + hub.marte = newMarteClient(hub) + + sub, err := fs.Sub(staticFiles, "static") + if err != nil { + log.Fatal(err) + } + fileServer := http.FileServer(http.FS(sub)) + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + wsHandler(hub, w, r) + }) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // Serve index.html for the root path explicitly. + if r.URL.Path == "/" { + data, err := staticFiles.ReadFile("static/index.html") + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) + return + } + fileServer.ServeHTTP(w, r) + }) + + log.Printf("MARTe2 Web Debug Client listening on %s", *addr) + log.Fatal(http.ListenAndServe(*addr, nil)) +} diff --git a/Tools/go_web_client/marte2-web-client b/Tools/go_web_client/marte2-web-client new file mode 100755 index 0000000..65fa405 Binary files /dev/null and b/Tools/go_web_client/marte2-web-client differ diff --git a/Tools/go_web_client/marte2.go b/Tools/go_web_client/marte2.go new file mode 100644 index 0000000..9ecf220 --- /dev/null +++ b/Tools/go_web_client/marte2.go @@ -0,0 +1,590 @@ +package main + +import ( + "bufio" + "encoding/binary" + "encoding/json" + "fmt" + "math" + "net" + "strings" + "sync" + "sync/atomic" + "time" +) + +// --------------------------------------------------------------------------- +// 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 WS message helpers +// --------------------------------------------------------------------------- + +type TelemetrySample struct { + ID uint32 `json:"id"` + Names []string `json:"names"` + Ts float64 `json:"ts"` + Values []float64 `json:"values"` +} + +func broadcast(hub *Hub, v any) { + b, err := json.Marshal(v) + if err != nil { + return + } + hub.broadcast(b) +} + +// --------------------------------------------------------------------------- +// MarteClient +// --------------------------------------------------------------------------- + +type MarteClient struct { + hub *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 + + baseTs uint64 + baseTsSet bool + basesMu sync.Mutex + + connected int32 // atomic bool + + stopCh chan struct{} + + // cached last-known ports (updated by SERVICE_INFO) + host string + cmdPort int + udpPort int + logPort int +} + +func newMarteClient(hub *Hub) *MarteClient { + return &MarteClient{ + hub: hub, + signals: make(map[uint32]*SignalMeta), + stopCh: make(chan struct{}), + } +} + +func (m *MarteClient) IsConnected() bool { + return atomic.LoadInt32(&m.connected) == 1 +} + +// SendCachedState sends the current signal list to a freshly connected browser. +func (m *MarteClient) SendCachedState(c *WSClient) { + m.sigMu.RLock() + defer m.sigMu.RUnlock() + if len(m.signals) == 0 { + return + } + sigs := make([]*SignalMeta, 0, len(m.signals)) + for _, s := range m.signals { + sigs = append(sigs, s) + } + b, _ := json.Marshal(map[string]any{"type": "signal_cache", "signals": sigs}) + select { + case c.send <- b: + default: + } +} + +// --------------------------------------------------------------------------- +// Connect / Disconnect +// --------------------------------------------------------------------------- + +func (m *MarteClient) 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() + + go m.runTCP(host, cmdPort) + go m.runUDP(host, udpPort) + go m.runLog(host, logPort) +} + +func (m *MarteClient) 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() +} + +func (m *MarteClient) stopped() bool { + select { + case <-m.stopCh: + return true + default: + return false + } +} + +// --------------------------------------------------------------------------- +// TCP command channel +// --------------------------------------------------------------------------- + +func (m *MarteClient) 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 { + 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) + broadcast(m.hub, map[string]any{"type": "connected"}) + + // Send SERVICE_INFO to auto-discover ports + m.writeCmd("SERVICE_INFO") + + m.readLoop(conn) + + atomic.StoreInt32(&m.connected, 0) + broadcast(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 *MarteClient) writeCmd(cmd string) { + m.cmdMu.Lock() + defer m.cmdMu.Unlock() + m.mu.Lock() + w := m.writer + m.mu.Unlock() + if w == nil { + return + } + w.WriteString(cmd + "\n") + w.Flush() +} + +func (m *MarteClient) SendCommand(cmd string) { + m.writeCmd(cmd) +} + +func (m *MarteClient) readLoop(conn net.Conn) { + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, 1*1024*1024), 1*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) + } + } +} + +// detectJSONDone returns (tag, true) when the line contains an "OK " sentinel. +func detectJSONDone(line string) (string, bool) { + tags := []string{ + "DISCOVER", "TREE", "INFO", "CONFIG", "STEP_STATUS", + "VALUE", "MSG", "TRACE", "FORCE", "UNFORCE", "BREAK", + "PAUSE", "RESUME", "STEP", "MONITOR", "UNMONITOR", "LS", + } + for _, t := range tags { + if strings.Contains(line, "OK "+t) { + return t, true + } + } + return "", false +} + +func (m *MarteClient) handleJSONResponse(tag, data string) { + switch tag { + case "DISCOVER": + m.parseDiscover(data) + } + // Forward raw JSON to all browsers. + broadcast(m.hub, map[string]any{ + "type": "response", + "tag": tag, + "data": data, + }) +} + +func (m *MarteClient) handleTextLine(line string) { + if strings.HasPrefix(line, "OK SERVICE_INFO") { + // OK SERVICE_INFO TCP_CTRL:8110 UDP_STREAM:8111 TCP_LOG:8082 STATE:RUNNING + 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) + } + } + if newUDP > 0 || newLog > 0 { + broadcast(m.hub, map[string]any{ + "type": "service_config", + "udp_port": newUDP, + "log_port": newLog, + }) + // Restart UDP/log workers with updated ports if they differ + 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.runUDP(host, newUDP) + } + if newLog > 0 && newLog != oldLog { + go m.runLog(host, newLog) + } + } + } + // Forward all text lines to browsers. + broadcast(m.hub, map[string]any{ + "type": "text_line", + "data": line, + }) +} + +// --------------------------------------------------------------------------- +// DISCOVER parsing +// --------------------------------------------------------------------------- + +type discoverResp struct { + Signals []struct { + Name string `json:"name"` + ID uint32 `json:"id"` + Type string `json:"type"` + Dimensions uint8 `json:"dimensions"` + Elements uint32 `json:"elements"` + } `json:"Signals"` +} + +func (m *MarteClient) parseDiscover(data string) { + var resp discoverResp + if err := json.Unmarshal([]byte(data), &resp); err != nil { + return + } + m.sigMu.Lock() + defer m.sigMu.Unlock() + m.signals = make(map[uint32]*SignalMeta, len(resp.Signals)) + for _, s := range resp.Signals { + el := s.Elements + if el == 0 { + el = 1 + } + 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 + } +} + +// --------------------------------------------------------------------------- +// UDP telemetry +// --------------------------------------------------------------------------- + +const udpMagic uint32 = 0xDA7A57AD + +func (m *MarteClient) runUDP(host string, port int) { + addr := fmt.Sprintf("0.0.0.0:%d", port) + udpAddr, err := net.ResolveUDPAddr("udp4", addr) + if err != nil { + return + } + conn, err := net.ListenUDP("udp4", udpAddr) + if err != nil { + return + } + defer conn.Close() + conn.SetReadBuffer(10 * 1024 * 1024) + conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + + buf := make([]byte, 65535) + var lastSeq uint32 + var hasLastSeq bool + var totalPkts uint64 + var dropped uint64 + + for !m.stopped() { + conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + continue + } + if n < 20 { + continue + } + if binary.LittleEndian.Uint32(buf[0:4]) != udpMagic { + continue + } + + seq := binary.LittleEndian.Uint32(buf[4:8]) + if hasLastSeq && seq > lastSeq+1 { + dropped += uint64(seq - lastSeq - 1) + } + lastSeq = seq + hasLastSeq = true + + totalPkts++ + if totalPkts%500 == 0 { + broadcast(m.hub, map[string]any{ + "type": "udp_stats", + "packets": totalPkts, + "dropped": dropped, + }) + } + + count := binary.LittleEndian.Uint32(buf[16:20]) + offset := 20 + + // Resolve base timestamp once per packet. + m.basesMu.Lock() + if !m.baseTsSet && n >= 20+12 { + m.baseTs = binary.LittleEndian.Uint64(buf[20+4 : 20+12]) + m.baseTsSet = true + } + baseTs := m.baseTs + baseTsSet := m.baseTsSet + m.basesMu.Unlock() + + m.sigMu.RLock() + samples := make([]TelemetrySample, 0, count) + for i := uint32(0); i < count; i++ { + if offset+16 > n { + break + } + id := binary.LittleEndian.Uint32(buf[offset : offset+4]) + tsRaw := binary.LittleEndian.Uint64(buf[offset+4 : offset+12]) + size := binary.LittleEndian.Uint32(buf[offset+12 : offset+16]) + offset += 16 + + if offset+int(size) > n { + break + } + dataSlice := buf[offset : offset+int(size)] + offset += int(size) + + tsS := 0.0 + if baseTsSet && tsRaw >= baseTs { + tsS = float64(tsRaw-baseTs) / 1_000_000.0 + } + + meta, ok := m.signals[id] + if !ok { + continue + } + + elements := meta.Elements + if elements == 0 { + elements = 1 + } + typeSize := uint32(size) + if elements > 0 { + typeSize = uint32(size) / elements + } + vals := make([]float64, 0, elements) + t := meta.Type + + for e := uint32(0); e < elements; e++ { + off := e * typeSize + if off+typeSize > uint32(len(dataSlice)) { + break + } + elem := dataSlice[off : off+typeSize] + vals = append(vals, parseTypedValue(elem, typeSize, t)) + } + + samples = append(samples, TelemetrySample{ + ID: id, + Names: meta.Names, + Ts: tsS, + Values: vals, + }) + } + m.sigMu.RUnlock() + + if len(samples) > 0 { + broadcast(m.hub, map[string]any{ + "type": "telemetry", + "seq": seq, + "signals": samples, + }) + } + } +} + +func parseTypedValue(data []byte, size uint32, t string) float64 { + switch size { + case 1: + if strings.Contains(t, "u") { + return float64(data[0]) + } + return float64(int8(data[0])) + case 2: + if len(data) < 2 { + return 0 + } + v := binary.LittleEndian.Uint16(data[:2]) + if strings.Contains(t, "u") { + return float64(v) + } + return float64(int16(v)) + case 4: + if len(data) < 4 { + return 0 + } + v := binary.LittleEndian.Uint32(data[:4]) + if strings.Contains(t, "float") || strings.Contains(t, "float32") { + return float64(math.Float32frombits(v)) + } + if strings.Contains(t, "u") { + return float64(v) + } + return float64(int32(v)) + case 8: + if len(data) < 8 { + return 0 + } + v := binary.LittleEndian.Uint64(data[:8]) + if strings.Contains(t, "float") || strings.Contains(t, "float64") || strings.Contains(t, "double") { + return math.Float64frombits(v) + } + if strings.Contains(t, "u") { + return float64(v) + } + return float64(int64(v)) + } + return 0 +} + +// --------------------------------------------------------------------------- +// TCP log channel +// --------------------------------------------------------------------------- + +func (m *MarteClient) 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:] + broadcast(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) + } + } +} diff --git a/Tools/go_web_client/static/app.js b/Tools/go_web_client/static/app.js new file mode 100644 index 0000000..b001c74 --- /dev/null +++ b/Tools/go_web_client/static/app.js @@ -0,0 +1,1127 @@ +/* global uPlot */ +'use strict'; + +// ════════════════════════════════════════════════════════════════════ +// State +// ════════════════════════════════════════════════════════════════════ + +const MAX_TRACE_PTS = 100000; +const COLORS = [ + '#89b4fa','#f38ba8','#a6e3a1','#fab387','#f9e2af', + '#cba6f7','#89dceb','#b4befe','#eba0ac','#94e2d5' +]; + +let ws = null; +let marteConnected = false; + +// Signal registry: id(number) → {id, name, type, elements, dimensions, names:[]} +let signalsById = {}; +// name → meta (same objects, multiple name entries per signal for array elems) +let signalsByName = {}; + +// Trace data: signalName → {ts:[], vals:[], lastVal:0, color:''} +let traceData = {}; +// Set of names currently being traced +let tracedSet = new Set(); +// Forced: name → value string +let forcedSignals = {}; +// Breakpoints: name → {op, threshold} +let breakpoints = {}; +// Thread list from TREE +let threads = []; +// All object paths (for autocomplete) +let allObjects = []; + +// Plots: [{id, container, chart, series:[{name, color, gain, offset}], maxPts, follow, dirty}] +let plots = []; +let plotSeq = 0; + +// Logs +let logs = []; +const MAX_LOGS = 2000; +let logDirty = false; + +// Step status +let stepStatus = null; // {paused, gam, remaining, thread} +let stepPollTimer = null; + +// Pending unforce target +let unforceTarget = ''; + +// UDP stats +let udpPackets = 0, udpDropped = 0; + +// ════════════════════════════════════════════════════════════════════ +// WebSocket +// ════════════════════════════════════════════════════════════════════ + +function connectWS() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(proto + '//' + location.host + '/ws'); + ws.onopen = () => { appendLog('sys','INFO','WebSocket connected to server'); }; + ws.onclose = () => { appendLog('sys','WARNING','WebSocket disconnected — reconnecting…'); setTimeout(connectWS, 2000); }; + ws.onerror = () => {}; + ws.onmessage = (e) => handleMsg(JSON.parse(e.data)); +} + +function sendWS(obj) { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); +} +function sendCmd(cmd) { sendWS({type:'cmd', data:{cmd}}); } + +// ════════════════════════════════════════════════════════════════════ +// Message handlers +// ════════════════════════════════════════════════════════════════════ + +function handleMsg(msg) { + switch (msg.type) { + case 'connected': onMarteConnected(); break; + case 'disconnected': onMarteDisconnected(); break; + case 'signal_cache': onSignalCache(msg); break; + case 'response': onResponse(msg); break; + case 'text_line': onTextLine(msg.data); break; + case 'telemetry': onTelemetry(msg); break; + case 'log': onLog(msg); break; + case 'service_config': onServiceConfig(msg); break; + case 'udp_stats': onUdpStats(msg); break; + } +} + +function onMarteConnected() { + marteConnected = true; + document.getElementById('conn-status').classList.add('ok'); + document.getElementById('btn-connect').textContent = 'Disconnect'; + appendLog('sys','INFO','Connected to MARTe2'); + // Auto-discover + sendCmd('DISCOVER'); + sendCmd('TREE'); + // Start polling step status + startStepPoll(); +} + +function onMarteDisconnected() { + marteConnected = false; + document.getElementById('conn-status').classList.remove('ok'); + document.getElementById('btn-connect').textContent = 'Connect'; + appendLog('sys','WARNING','Disconnected from MARTe2'); + stopStepPoll(); +} + +function onSignalCache(msg) { + // Server sends cached signal list when a new browser connects mid-session + if (msg.signals) processDiscovery(msg.signals); +} + +function onResponse(msg) { + try { + const data = msg.data ? JSON.parse(msg.data) : null; + switch (msg.tag) { + case 'DISCOVER': if (data) processDiscovery(data.Signals || []); break; + case 'TREE': if (data) processTree(data); break; + case 'INFO': showInfo(msg.tag, msg.data); break; + case 'CONFIG': showInfo('CONFIG', msg.data); break; + case 'STEP_STATUS': processStepStatus(data); break; + case 'VALUE': processValue(data); break; + } + } catch(e) { + // non-JSON responses (TRACE, FORCE, etc.) — silently ignore + } +} + +function onTextLine(line) { + if (line.startsWith('OK ')) return; // handled by response handler + appendLog('sys','DEBUG', line); +} + +function onLog(msg) { + // Normalise level names from MARTe2 + let lv = msg.level; + if (lv === 'Information') lv = 'INFO'; + else if (lv === 'Warning') lv = 'WARNING'; + else if (lv === 'Debug') lv = 'DEBUG'; + else if (lv === 'FatalError' || lv === 'ParametersError' || lv === 'OSError') lv = 'ERROR'; + appendLog(msg.time, lv, msg.message); +} + +function onServiceConfig(msg) { + appendLog('sys','INFO',`ServiceConfig: UDP=${msg.udp_port} LOG=${msg.log_port}`); +} + +function onUdpStats(msg) { + udpPackets = msg.packets; + udpDropped = msg.dropped; + document.getElementById('udp-stats').textContent = + `${udpPackets} pkts${udpDropped>0?' ⚠'+udpDropped+' drop':''}`; +} + +function onTelemetry(msg) { + if (!msg.signals) return; + for (const s of msg.signals) { + const names = s.names || []; + const vals = s.values || []; + for (let e = 0; e < vals.length; e++) { + const n = vals.length > 1 ? `${names[0]}[${e}]` : (names[0] || ''); + if (!n || !tracedSet.has(n)) continue; + let td = traceData[n]; + if (!td) { + td = {ts:[], vals:[], lastVal:0, color: nextColor()}; + traceData[n] = td; + } + td.ts.push(s.ts); + td.vals.push(vals[e]); + td.lastVal = vals[e]; + // Trim to max points + if (td.ts.length > MAX_TRACE_PTS) { + const trim = td.ts.length - MAX_TRACE_PTS; + td.ts.splice(0, trim); + td.vals.splice(0, trim); + } + } + // Mark plots dirty + for (const p of plots) { + for (const sr of p.series) { + if (names.includes(sr.name) || (vals.length>1 && names.length>0 && + p.series.some(x => x.name.startsWith(names[0])))) { + p.dirty = true; + break; + } + } + } + } + // Update only the value spans — never rebuild the DOM here + updateTracedValues(); +} + +// ════════════════════════════════════════════════════════════════════ +// Discovery +// ════════════════════════════════════════════════════════════════════ + +function processDiscovery(sigs) { + signalsById = {}; + signalsByName = {}; + for (const s of sigs) { + const el = s.elements || 1; + const meta = {id:s.id, name:s.name, type:s.type||'', elements:el, dimensions:s.dimensions||0, names:[s.name]}; + signalsById[s.id] = meta; + signalsByName[s.name] = meta; + // Array element names + if (el > 1) { + for (let i=0; i!n.includes('[')); + ['force-sig-list','break-sig-list'].forEach(id=>{ + const dl = document.getElementById(id); + dl.innerHTML = names.map(n=>`' + + threads.map(t=>``).join(''); + } else if (el.tagName === 'DATALIST') { + el.innerHTML = threads.map(t=>`