Initial release

This commit is contained in:
Martino Ferrari
2026-05-29 13:29:59 +02:00
commit 617b5bd712
110 changed files with 29234 additions and 0 deletions
+895
View File
@@ -0,0 +1,895 @@
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)
}
}
}