diff --git a/cli/runcli.go b/cli/runcli.go index 0fbf9e95..445b980e 100644 --- a/cli/runcli.go +++ b/cli/runcli.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2023, The OTNS Authors. +// Copyright (c) 2020-2026, The OTNS Authors. // All rights reserved. // // Redistribution and use in source and binary forms, with or without @@ -175,9 +175,12 @@ func (cli *CliInstance) Run(handler CliHandler, options *CliOptions) error { cli.readlineInstance = l close(cli.Started) + cliStdoutAndFileWriter := io.MultiWriter(l.Stdout(), logger.GetLogWriter()) + for { // update the prompt and read a line - l.SetPrompt(handler.GetPrompt()) + currentPrompt := handler.GetPrompt() + l.SetPrompt(currentPrompt) line, err := l.Readline() if len(line) > 0 && line[0] == readline.CharInterrupt { @@ -203,12 +206,14 @@ func (cli *CliInstance) Run(handler CliHandler, options *CliOptions) error { cmd := strings.TrimSpace(line) if len(cmd) == 0 { - stdout.WriteString("") + _, _ = stdout.WriteString("") _ = stdout.Sync() continue } - if err = handler.HandleCommand(cmd, l.Stdout()); err != nil { + logger.Println(currentPrompt+line, false, true) // echo user cmd to OTNS logfile + + if err = handler.HandleCommand(cmd, cliStdoutAndFileWriter); err != nil { _ = stdout.Sync() return err } diff --git a/cmd/otns-replay/otns_replay.go b/cmd/otns-replay/otns_replay.go index 63f410b1..a69fbb28 100644 --- a/cmd/otns-replay/otns_replay.go +++ b/cmd/otns-replay/otns_replay.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022-2025, The OTNS Authors. +// Copyright (c) 2022-2026, The OTNS Authors. // All rights reserved. // // Redistribution and use in source and binary forms, with or without @@ -50,7 +50,7 @@ var args struct { func parseArgs() { flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) - fmt.Fprintf(os.Stderr, " Replays a prior simulation in the Web GUI based on a .replay file.\n") + fmt.Fprintln(os.Stderr, " Replays a prior simulation in the Web GUI based on a .replay file.") } flag.Parse() diff --git a/dispatcher/dispatcher.go b/dispatcher/dispatcher.go index 419d053e..3e671ef2 100644 --- a/dispatcher/dispatcher.go +++ b/dispatcher/dispatcher.go @@ -40,6 +40,7 @@ import ( "strconv" "strings" "sync" + "syscall" "time" "github.com/openthread/ot-ns/dissectpkt" @@ -152,7 +153,7 @@ type Dispatcher struct { func NewDispatcher(ctx *progctx.ProgCtx, cfg *Config, cbHandler CallbackHandler) *Dispatcher { logger.AssertTrue(!cfg.Realtime || cfg.Speed == 1) var err error - ln, unixSocketFile := newUnixSocket(cfg.SimulationId) + ln, unixSocketFile := newUnixSocket(cfg.OutputDir, cfg.SimulationId) vis := visualize.NewNopVisualizer() d := &Dispatcher{ @@ -184,7 +185,7 @@ func NewDispatcher(ctx *progctx.ProgCtx, cfg *Config, cbHandler CallbackHandler) } d.speed = d.normalizeSpeed(d.speed) if d.cfg.PcapEnabled { - d.pcap, err = pcap.NewFile("current.pcap", cfg.PcapFrameType, true) + d.pcap, err = pcap.NewFile(d.getPcapFileName(), cfg.PcapFrameType, true) logger.PanicIfError(err) d.waitGroup.Add(1) go d.pcapFrameWriter() @@ -199,17 +200,27 @@ func NewDispatcher(ctx *progctx.ProgCtx, cfg *Config, cbHandler CallbackHandler) return d } -func newUnixSocket(socketId int) (net.Listener, string) { - err := os.MkdirAll("/tmp/otns", 0777) - logger.FatalIfError(err, err) - unixSocketFile := fmt.Sprintf("/tmp/otns/socket_dispatcher_%d", socketId) // remove old one - err = os.RemoveAll(unixSocketFile) +func newUnixSocket(socketDir string, socketId int) (net.Listener, string) { + unixSocketFile := fmt.Sprintf("%s/socket_%d", socketDir, socketId) // remove old socket + + if !isValidUnixSocketPath(unixSocketFile) { + logger.Fatalf("unix socket path too long: %s", unixSocketFile) + } + + err := os.Remove(unixSocketFile) + if err != nil && errors.Is(err, os.ErrNotExist) { + err = nil + } logger.FatalIfError(err, err) ln, err := net.Listen("unix", unixSocketFile) logger.FatalIfError(err, err) return ln, unixSocketFile } +func isValidUnixSocketPath(path string) bool { + return len(path) < len(syscall.RawSockaddrUnix{}.Path) +} + func (d *Dispatcher) Stop() { if d.stopped { return @@ -652,7 +663,7 @@ func (d *Dispatcher) processNextEvents(simSpeed float64) bool { func (d *Dispatcher) eventsReader() { defer d.waitGroup.Done() defer logger.Tracef("dispatcher node socket threads stopped.") - defer os.RemoveAll(d.socketName) // delete Unix socket file when done. + defer os.Remove(d.socketName) // delete Unix socket file when done. defer d.udpln.Close() logger.Debugf("dispatcher listening on socket %s ...", d.socketName) @@ -1455,7 +1466,7 @@ func (d *Dispatcher) dumpPacket(item *Event) { _, _ = fmt.Fprintf(&sb, "%02X", b) } - logger.Println(sb.String()) + logger.Println(sb.String(), true, true) } func (d *Dispatcher) setNodeRole(node *Node, role OtDeviceRole) { @@ -1572,3 +1583,7 @@ func (d *Dispatcher) handleRadioState(node *Node, evt *Event) { }) } } + +func (d *Dispatcher) getPcapFileName() string { + return fmt.Sprintf("%s/%d_otns.pcap", d.cfg.OutputDir, d.cfg.SimulationId) +} diff --git a/dispatcher/dispatcher_config.go b/dispatcher/dispatcher_config.go index 1be51ae0..f4781070 100644 --- a/dispatcher/dispatcher_config.go +++ b/dispatcher/dispatcher_config.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2024, The OTNS Authors. +// Copyright (c) 2020-2026, The OTNS Authors. // All rights reserved. // // Redistribution and use in source and binary forms, with or without @@ -54,7 +54,7 @@ func DefaultConfig() *Config { DefaultWatchOn: false, DefaultWatchLevel: logger.OffLevelString, SimulationId: 0, - OutputDir: "tmp", + OutputDir: "", PhyTxStats: false, } } diff --git a/energy/core.go b/energy/core.go index a7597dbd..d2ac9326 100644 --- a/energy/core.go +++ b/energy/core.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022-2024, The OTNS Authors. +// Copyright (c) 2022-2026, The OTNS Authors. // All rights reserved. // // Redistribution and use in source and binary forms, with or without @@ -146,7 +146,7 @@ func (e *EnergyAnalyser) SaveEnergyDataToFile(name string, timestamp uint64) { func (e *EnergyAnalyser) writeEnergyByNodes(fileNodes *os.File, timestamp uint64) { fmt.Fprintf(fileNodes, "Duration of the simulated network (in milliseconds): %d\n", timestamp/1000) - fmt.Fprintf(fileNodes, "ID\tDisabled (mJ)\tIdle (mJ)\tTransmiting (mJ)\tReceiving (mJ)\n") + fmt.Fprintln(fileNodes, "ID\tDisabled (mJ)\tIdle (mJ)\tTransmiting (mJ)\tReceiving (mJ)") sortedNodes := make([]int, 0, len(e.nodes)) for id := range e.nodes { @@ -168,7 +168,7 @@ func (e *EnergyAnalyser) writeEnergyByNodes(fileNodes *os.File, timestamp uint64 func (e *EnergyAnalyser) writeNetworkEnergy(fileNetwork *os.File, timestamp uint64) { fmt.Fprintf(fileNetwork, "Duration of the simulated network (in milliseconds): %d\n", timestamp/1000) - fmt.Fprintf(fileNetwork, "Time (ms)\tDisabled (mJ)\tIdle (mJ)\tTransmiting (mJ)\tReceiving (mJ)\n") + fmt.Fprintln(fileNetwork, "Time (ms)\tDisabled (mJ)\tIdle (mJ)\tTransmiting (mJ)\tReceiving (mJ)") for _, snapshot := range e.networkHistory { fmt.Fprintf(fileNetwork, "%d\t%f\t%f\t%f\t%f\n", snapshot.Timestamp/1000, diff --git a/go.mod b/go.mod index a355767a..d3cc24ea 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/stretchr/testify v1.9.0 go.uber.org/zap v1.24.0 golang.org/x/net v0.38.0 - golang.org/x/term v0.30.0 + golang.org/x/term v0.34.0 google.golang.org/grpc v1.56.3 google.golang.org/protobuf v1.34.1 gopkg.in/yaml.v3 v3.0.1 @@ -48,7 +48,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.23.0 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect ) diff --git a/logger/logger.go b/logger/logger.go index 0b0a2333..a01c0c39 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2024, The OTNS Authors. +// Copyright (c) 2023-2026, The OTNS Authors. // All rights reserved. // // Redistribution and use in source and binary forms, with or without @@ -29,6 +29,7 @@ package logger import ( "encoding/json" "fmt" + "io" "os" "runtime/debug" "time" @@ -36,6 +37,7 @@ import ( "github.com/stretchr/testify/assert" "go.uber.org/zap" "go.uber.org/zap/zapcore" + "golang.org/x/term" . "github.com/openthread/ot-ns/types" ) @@ -69,47 +71,84 @@ type StdoutCallback interface { OnStdout() } +const ( + dateTimeFormat = "2006-01-02 15:04:05.000" +) + var ( - cfg zap.Config - zaplogger *zap.Logger - currentLevel Level - isLogToTerminal bool - cbStdout StdoutCallback - zapLevels = []zapcore.Level{zapcore.FatalLevel + 1, zapcore.FatalLevel, zapcore.PanicLevel, + cfg zap.Config + zaplogger *zap.Logger + currentLevel Level = DefaultLevel + isLogToTerminal = true + cbStdout StdoutCallback = nil + logFileHandle *os.File = nil + logPath = "" + logFileWriterInst = &logFileWriter{} + zapLevels = []zapcore.Level{zapcore.FatalLevel + 1, zapcore.FatalLevel, zapcore.PanicLevel, zapcore.ErrorLevel, zapcore.WarnLevel, zapcore.InfoLevel, zapcore.InfoLevel, zapcore.DebugLevel, zapcore.DebugLevel, zapcore.DebugLevel} ) func init() { - o, _ := os.Stdout.Stat() - if (o.Mode() & os.ModeCharDevice) == os.ModeCharDevice { - isLogToTerminal = true + cfgJson := []byte(`{ + "level": "debug", + "outputPaths": ["stderr"], + "errorOutputPaths": ["stderr"], + "encoding": "console", + "encoderConfig": { + "messageKey": "message", + "levelKey": "level", + "levelEncoder": "lowercase", + "timeKey": "timestamp", + "timeEncoder": "iso8601" + } + }`) + + if err := json.Unmarshal(cfgJson, &cfg); err != nil { + panic(err) } - var err error - cfgJson := []byte(`{ - "level": "debug", - "outputPaths": ["stderr"], - "errorOutputPaths": ["stderr"], - "encoding": "console", - "encoderConfig": { - "messageKey": "message", - "levelKey": "level", - "levelEncoder": "lowercase" + cfg.EncoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendString(t.Format(dateTimeFormat)) } -}`) - currentLevel = DefaultLevel - if err = json.Unmarshal(cfgJson, &cfg); err != nil { - panic(err) + rebuildLoggerFromCfg() +} + +func Init(logToStdout bool, logToFile bool, logFileName string, simId int) { + var err error + logPath = logFileName + + if logToFile { + // Open the log file here (not inside Zap) so we can store the file handle in our package. + // os.O_APPEND is used to enable multiple goroutines to write to the same handle. + // os.O_TRUNC ensures any prior log file from a previous run is overwritten cleanly. + logFileHandle, err = os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + Errorf("Error: failed to open log file: %v\n", err) + } else { + header := fmt.Sprintf("#\n# OTNS log for sim-ID %d created %s\n#\n", simId, + time.Now().Format(time.RFC3339)) + _, err = logFileHandle.WriteString(header) + if err != nil { + Errorf("Error: failed to write log file header: %v\n", err) + _ = logFileHandle.Close() + logFileHandle = nil + } + } } - cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + + isLogToTerminal = logToStdout && term.IsTerminal(int(os.Stdout.Fd())) + rebuildLoggerFromCfg() } // SetLevel sets the log level func SetLevel(lv Level) { - currentLevel = lv + if currentLevel != lv { + currentLevel = lv + Println(fmt.Sprintf("%s Log level changed to %s", time.Now().Format(dateTimeFormat), GetLevelString(lv)), false, true) + } } // GetLevel get the current log level @@ -117,6 +156,21 @@ func GetLevel() Level { return currentLevel } +// GetLogWriter returns an io.Writer that writes directly to the log file, or to /dev/null in case +// there is no log file active. +func GetLogWriter() io.Writer { + return logFileWriterInst +} + +type logFileWriter struct{} + +func (w *logFileWriter) Write(p []byte) (n int, err error) { + if logFileHandle != nil { + return logFileHandle.Write(p) + } + return len(p), nil +} + // SetStdoutCallback sets a callback, that the logger will call when new log content was written to stdout/stderr. func SetStdoutCallback(cb StdoutCallback) { cbStdout = cb @@ -128,22 +182,29 @@ func TraceError(format string, args ...interface{}) { Errorf(format, args...) } -// SetOutput sets the output writer -// e.g. logger.SetOutput([]string{"stderr", "otns.log"}) // for @DEBUG: generate a log output file. -func SetOutput(outputs []string) { - cfg.OutputPaths = outputs - rebuildLoggerFromCfg() -} - func rebuildLoggerFromCfg() { - if newLogger, err := cfg.Build(); err == nil { - if zaplogger != nil { - _ = zaplogger.Sync() - } - zaplogger = newLogger - } else { - panic(err) + if zaplogger != nil { + _ = zaplogger.Sync() } + + encoder := zapcore.NewConsoleEncoder(cfg.EncoderConfig) + // Accept all levels — Go-level checks in Logf/logAlways gate what actually reaches zap. + allLevels := zap.LevelEnablerFunc(func(zapcore.Level) bool { return true }) + + var core zapcore.Core + switch { + case logFileHandle != nil && isLogToTerminal: + core = zapcore.NewTee( + zapcore.NewCore(encoder, zapcore.AddSync(os.Stderr), allLevels), + zapcore.NewCore(encoder, zapcore.AddSync(logFileHandle), allLevels), + ) + case logFileHandle != nil: + core = zapcore.NewCore(encoder, zapcore.AddSync(logFileHandle), allLevels) + default: + core = zapcore.NewCore(encoder, zapcore.AddSync(os.Stderr), allLevels) + } + + zaplogger = zap.New(core) } // getMessage formats a string efficiently with Sprint, Sprintf, or neither. @@ -180,33 +241,36 @@ func Logf(level Level, format string, args []interface{}) { if isLogToTerminal { _, _ = fmt.Fprint(os.Stdout, "\033[2K\r") // ANSI sequence to clear the CLI line } - timeStr := time.Now().Format("2006-01-02 15:04:05.000") + " - " - zaplogger.Log(zapLevels[level-MinLevel], timeStr+getMessage(format, args)) + zaplogger.Log(zapLevels[level-MinLevel], getMessage(format, args)) if isLogToTerminal && cbStdout != nil { cbStdout.OnStdout() } } -// logAlways is a helper func that doesn't check level prior to logging to zaplogger. +// logAlways is a helper func that doesn't check level and always logs to zaplogger. func logAlways(level Level, msg string) { if isLogToTerminal { _, _ = fmt.Fprint(os.Stdout, "\033[2K\r") // ANSI sequence to clear the CLI line } - timeStr := time.Now().Format("2006-01-02 15:04:05.000") + " - " - zaplogger.Log(zapLevels[level-MinLevel], timeStr+msg) + zaplogger.Log(zapLevels[level-MinLevel], msg) if isLogToTerminal && cbStdout != nil { cbStdout.OnStdout() } } -// Println prints a message for the user at the current console/CLI, to stdout, without logging fields. -func Println(msg string) { - if isLogToTerminal { - _, _ = fmt.Fprint(os.Stdout, "\033[2K\r") // ANSI sequence to clear the CLI line +// Println prints a message to console and/or log file, without using any log line formatting. +func Println(msg string, toConsole bool, toLogFile bool) { + if toConsole { + if isLogToTerminal { + _, _ = fmt.Fprint(os.Stdout, "\033[2K\r") // ANSI sequence to clear the CLI line + } + _, _ = fmt.Fprintln(os.Stdout, msg) + if isLogToTerminal && cbStdout != nil { + cbStdout.OnStdout() + } } - _, _ = fmt.Fprint(os.Stdout, msg+"\n") - if isLogToTerminal && cbStdout != nil { - cbStdout.OnStdout() + if toLogFile && logFileHandle != nil { + _, _ = logFileHandle.WriteString(msg + "\n") } } diff --git a/logger/logger_test.go b/logger/logger_test.go new file mode 100644 index 00000000..6d0937b8 --- /dev/null +++ b/logger/logger_test.go @@ -0,0 +1,190 @@ +// Copyright (c) 2026, The OTNS Authors. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// 3. Neither the name of the copyright holder nor the +// names of its contributors may be used to endorse or promote products +// derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +package logger + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + tmpDir string +) + +func init() { + tmpDir = filepath.Join(os.TempDir(), "otns-logger-test-424387") +} + +func cleanupLogger() { + if logFileHandle != nil { + _ = logFileHandle.Close() + logFileHandle = nil // Reset package variable + } + zaplogger = nil + currentLevel = DefaultLevel + isLogToTerminal = true + cbStdout = nil + logFileHandle = nil + logPath = "" + cfg.OutputPaths = []string{"stderr"} + rebuildLoggerFromCfg() +} + +func TestClearExistingLogFile(t *testing.T) { + t.Cleanup(cleanupLogger) + + // Create temporary directory for the test + err := os.Mkdir(tmpDir, 0755) + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create the initial log file + logFile := filepath.Join(tmpDir, "test.log") + err = os.WriteFile(logFile, []byte("initial content\n"), 0644) + assert.NoError(t, err) + + // Make the directory non-writable, which should prevent removing the file on Linux + err = os.Chmod(tmpDir, 0555) + assert.NoError(t, err) + defer func() { _ = os.Chmod(tmpDir, 0755) }() // Ensure we can clean up + + // Call Init. It should be able to open it and clear file contents. + Init(true, true, logFile, 0) + + // Verify that the file handle is set (it should be, since the file exists and is writable) + assert.NotNil(t, logFileHandle) + + // Verify we can log to the file + Infof("Test log message 1234") + + // Check that the file contains the logged line + content, err := os.ReadFile(logFile) + assert.NoError(t, err) + assert.Contains(t, string(content), "Test log message 1234") + + // Check that the file was cleared. + assert.NotContains(t, string(content), "initial content") +} + +func TestInitReadOnlyFile(t *testing.T) { + t.Cleanup(cleanupLogger) + + // Create temporary directory for the test + err := os.Mkdir(tmpDir, 0755) + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create the initial log file and make it read-only + logFile := filepath.Join(tmpDir, "test.log") + err = os.WriteFile(logFile, []byte("initial content\n"), 0444) + assert.NoError(t, err) + + // Call Init. It should attempt to remove the file. + // Removing a read-only file in a writable directory works on Linux. + // So let's make the directory non-writable too, to ensure remove fails, + // AND the file is read-only, so opening it for WRONLY also fails. + err = os.Chmod(tmpDir, 0555) + assert.NoError(t, err) + defer func() { _ = os.Chmod(tmpDir, 0755) }() + + Init(true, true, logFile, 0) + + // Verify that the file handle is nil because file-open should have failed. + assert.Nil(t, logFileHandle) + + // Verify we can log + Infof("Test log message 4567") +} + +func TestPrintln(t *testing.T) { + t.Cleanup(cleanupLogger) + + // Create temporary directory for the test + err := os.Mkdir(tmpDir, 0755) + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Initialize logger (including file) + logFile := filepath.Join(tmpDir, "test-levels.log") + Init(false, true, logFile, 0) + + // Log messages + Println("A: should be logged", false, true) + Println("B: should be logged", true, true) + Println("C: should NOT be logged", true, false) + Println("D: should NOT be logged", false, false) + + // Verify content + content, err := os.ReadFile(logFile) + assert.NoError(t, err) + s := string(content) + assert.Contains(t, s, "A: should be logged") + assert.Contains(t, s, "B: should be logged") + assert.NotContains(t, s, "C: should NOT be logged") + assert.NotContains(t, s, "D: should NOT be logged") + + // More log messages + Println("E: should be logged now", true, true) + + content, err = os.ReadFile(logFile) + assert.NoError(t, err) + s = string(content) + assert.Contains(t, s, "E: should be logged now") +} + +func TestGetLogWriter(t *testing.T) { + t.Cleanup(cleanupLogger) + + // Case 1: logFileHandle is nil + writer := GetLogWriter() + assert.NotNil(t, writer) + _, err := writer.Write([]byte("test data when nil\n")) + assert.NoError(t, err) + + // Case 2: logFileHandle is open + err = os.Mkdir(tmpDir, 0755) + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + logFile := filepath.Join(tmpDir, "test-writer.log") + Init(false, true, logFile, 0) + + writer = GetLogWriter() + assert.NotNil(t, writer) + + // Case 2a: Write message that should be logged to file + msg := "test data to writer" + _, err = writer.Write([]byte(msg + "\n")) + assert.NoError(t, err) + + content, err := os.ReadFile(logFile) + assert.NoError(t, err) + assert.Contains(t, string(content), msg) +} diff --git a/logger/nodelogger.go b/logger/nodelogger.go index 80cd35c6..9e288ce3 100644 --- a/logger/nodelogger.go +++ b/logger/nodelogger.go @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2024, The OTNS Authors. +// Copyright (c) 2023-2026, The OTNS Authors. // All rights reserved. // // Redistribution and use in source and binary forms, with or without @@ -91,7 +91,7 @@ func getLogFileName(outputPath string, simId int, nodeId NodeId) string { func (nl *NodeLogger) createLogFile() { var err error - nl.logFile, err = os.OpenFile(nl.logFileName, os.O_CREATE|os.O_WRONLY, 0664) + nl.logFile, err = os.OpenFile(nl.logFileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0664) if err != nil { nl.Errorf("creating node log file %s failed: %+v", nl.logFileName, err) nl.isFileEnabled = false @@ -106,7 +106,8 @@ func (nl *NodeLogger) openLogFile() { AssertTrue(nl.logFile == nil) var err error - nl.logFile, err = os.OpenFile(nl.logFileName, os.O_APPEND|os.O_WRONLY, 0664) + // Node restarted with same ID in this simulation: append to the existing log file. + nl.logFile, err = os.OpenFile(nl.logFileName, os.O_WRONLY|os.O_APPEND, 0664) if err != nil { nl.Errorf("opening node log file %s failed: %+v", nl.logFileName, err) nl.isFileEnabled = false diff --git a/ot-rfsim/src/flash.c b/ot-rfsim/src/flash.c index efc59560..62534035 100644 --- a/ot-rfsim/src/flash.c +++ b/ot-rfsim/src/flash.c @@ -54,8 +54,9 @@ enum void otPlatFlashInit(otInstance *aInstance) { - const char *path = OPENTHREAD_CONFIG_POSIX_SETTINGS_PATH; - char fileName[sizeof(OPENTHREAD_CONFIG_POSIX_SETTINGS_PATH) + 32]; + const char *envPath = getenv("OTNS_DATA_PATH"); + const char *path = (envPath != NULL) ? envPath : OPENTHREAD_CONFIG_POSIX_SETTINGS_PATH; + char fileName[512]; struct stat st; bool create = false; const char *offset = getenv("PORT_OFFSET"); diff --git a/otns_main/otns_main.go b/otns_main/otns_main.go index 6caadccc..1becde65 100644 --- a/otns_main/otns_main.go +++ b/otns_main/otns_main.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2025, The OTNS Authors. +// Copyright (c) 2020-2026, The OTNS Authors. // All rights reserved. // // Redistribution and use in source and binary forms, with or without @@ -60,7 +60,8 @@ type MainArgs struct { AutoGo bool ReadOnly bool LogLevel string - LogFileLevel string + LogStdout bool + LogNodeLevel string WatchLevel string OpenWeb bool Realtime bool @@ -69,9 +70,10 @@ type MainArgs struct { DispatcherPort int DumpPackets bool PcapType string - NoReplay bool + Replay bool RandomSeed int64 PhyTxStats bool + OutputDir string } var ( @@ -97,16 +99,18 @@ func parseArgs() { flag.BoolVar(&args.AutoGo, "autogo", true, "auto go (runs the simulation at given speed, without issuing 'go' commands.)") flag.BoolVar(&args.ReadOnly, "readonly", false, "readonly simulation can not be manipulated") flag.StringVar(&args.LogLevel, "log", "warn", "set OTNS display logging level: trace, debug, info, warn, error.") - flag.StringVar(&args.LogFileLevel, "logfile", "debug", "set OTNS + node file logging level: trace, debug, info, warn, error, off.") + flag.BoolVar(&args.LogStdout, "log-stdout", true, "write OTNS log output to console stdout/stderr ('false' logs to file only)") + flag.StringVar(&args.LogNodeLevel, "log-node", "debug", "set OT node (file) logging level: trace, debug, info, warn, error, off.") flag.StringVar(&args.WatchLevel, "watch", "off", "set default watch (display) level for new nodes: trace, debug, info, note, warn, error, off.") flag.BoolVar(&args.OpenWeb, "web", true, "open web visualization") flag.BoolVar(&args.Realtime, "realtime", false, "use real-time mode (forced speed=1 and autogo)") flag.StringVar(&args.ListenAddr, "listen", fmt.Sprintf("localhost:%d", InitialDispatcherPort), "specify TCP/UDP host and port base value for web-GUI/RPC. Recommended ports are 9000, 9010, 9020, etc.") flag.BoolVar(&args.DumpPackets, "dump-packets", false, "dump packets") - flag.StringVar(&args.PcapType, "pcap", pcap.FrameTypeWpanStr, "PCAP file type: 'off', 'wpan', or 'wpan-tap'. PCAP is saved to file 'current.pcap'.") - flag.BoolVar(&args.NoReplay, "no-replay", false, "do not generate Replay file (named \"otns_?.replay\")") + flag.StringVar(&args.PcapType, "pcap", pcap.FrameTypeWpanStr, "PCAP output file type: 'off', 'wpan', or 'wpan-tap'.") + flag.BoolVar(&args.Replay, "replay", false, "generate simulation replay file (named '?_otns.replay')") flag.Int64Var(&args.RandomSeed, "seed", 0, "set specific random-seed value (for reproducability)") flag.BoolVar(&args.PhyTxStats, "phy-tx-stats", false, "generate PHY Tx statistics CSV file") + flag.StringVar(&args.OutputDir, "output", "tmp", "specify output directory for simulation results and logs") flag.Parse() } @@ -135,21 +139,49 @@ func parseListenAddr() (int, error) { return simId, err } +func validateArgs() error { + outputExplicit := false + flag.Visit(func(f *flag.Flag) { + if f.Name == "output" { + outputExplicit = true + } + }) + if args.Realtime && outputExplicit { + return errors.New("-realtime cannot be combined with -output: realtime OT nodes may write flash files to './tmp' regardless of the -output directory") + } + if args.Realtime && args.RandomSeed != 0 { + return errors.New("-realtime cannot be combined with -seed: real-time simulations with external/Posix nodes are not fully reproducible") + } + + return nil +} + +func quitOnError(err error) { + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + func Main(ctx *progctx.ProgCtx, cliOptions *cli.CliOptions) { handleSignals(ctx) parseArgs() + quitOnError(validateArgs()) + quitOnError(ensureOutputDirExists(args.OutputDir)) + simId, err := parseListenAddr() - logger.FatalIfError(err) + quitOnError(err) + logger.Init(args.LogStdout, true, getOtnsLogFileName(simId), simId) prng.Init(args.RandomSeed) sim, err := createSimulation(simId, ctx) - logger.FatalIfError(err) + logger.FatalIfError(err, err) visGrpcServerAddr := fmt.Sprintf("%s:%d", args.DispatcherHost, args.DispatcherPort-1) replayFn := "" - if !args.NoReplay { - replayFn = fmt.Sprintf("otns_%d.replay", simId) + if args.Replay { + replayFn = getOtnsReplayFileName(simId) } chanGrpcClientNotifier := make(chan string, 1) @@ -253,16 +285,17 @@ func createSimulation(simId int, ctx *progctx.ProgCtx) (*simulation.Simulation, simcfg := simulation.DefaultConfig() + simcfg.OutputDir = args.OutputDir simcfg.LogLevel, err = logger.ParseLevelString(args.LogLevel) if err != nil { return nil, err } logger.SetLevel(simcfg.LogLevel) - simcfg.LogFileLevel, err = logger.ParseLevelString(args.LogFileLevel) + simcfg.LogNodeLevel, err = logger.ParseLevelString(args.LogNodeLevel) if err != nil { return nil, err } - if args.LogFileLevel == logger.NoneLevelString || args.LogFileLevel == logger.OffLevelString { + if args.LogNodeLevel == logger.NoneLevelString || args.LogNodeLevel == logger.OffLevelString { simcfg.NewNodeConfig.NodeLogFile = false } simcfg.ExeConfig.Ftd = args.OtCliPath @@ -298,10 +331,12 @@ func createSimulation(simId int, ctx *progctx.ProgCtx) (*simulation.Simulation, dispatcherCfg := dispatcher.DefaultConfig() dispatcherCfg.SimulationId = simcfg.Id + dispatcherCfg.OutputDir = args.OutputDir dispatcherCfg.PcapEnabled = args.PcapType != pcap.FrameTypeOffStr dispatcherCfg.PcapFrameType = pcap.ParseFrameTypeStr(args.PcapType) if dispatcherCfg.PcapFrameType == pcap.FrameTypeUnknown { - logger.Fatalf("Unknown PCAP frame type '%s', use -h flag for an overview.", args.PcapType) + err = fmt.Errorf("unknown PCAP frame type '%s', use -h flag for an overview", args.PcapType) + quitOnError(err) } dispatcherCfg.DefaultWatchLevel = args.WatchLevel watchLevel, err := logger.ParseLevelString(args.WatchLevel) @@ -314,3 +349,15 @@ func createSimulation(simId int, ctx *progctx.ProgCtx) (*simulation.Simulation, sim, err := simulation.NewSimulation(ctx, simcfg, dispatcherCfg) return sim, err } + +func ensureOutputDirExists(outputDir string) error { + return os.MkdirAll(outputDir, 0775) +} + +func getOtnsLogFileName(simId int) string { + return fmt.Sprintf("%s/%d_otns.log", args.OutputDir, simId) +} + +func getOtnsReplayFileName(simId int) string { + return fmt.Sprintf("%s/%d_otns.replay", args.OutputDir, simId) +} diff --git a/pylibs/case_studies/deprecated_prefix.py b/pylibs/case_studies/deprecated_prefix.py index 7264ea82..bb702768 100755 --- a/pylibs/case_studies/deprecated_prefix.py +++ b/pylibs/case_studies/deprecated_prefix.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2023, The OTNS Authors. +# Copyright (c) 2023-2026, The OTNS Authors. # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -25,8 +25,8 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# Case study on routing when a prefix becomes deprecated. Requires loading current.pcap -# into Wireshark to see the results. +# Case study on routing when a prefix becomes deprecated. Requires loading the output +# PCAP file into Wireshark to see the results. import logging from otns.cli import OTNS diff --git a/pylibs/case_studies/fragment_reassembly.py b/pylibs/case_studies/fragment_reassembly.py index 3287ce19..ca8444c7 100755 --- a/pylibs/case_studies/fragment_reassembly.py +++ b/pylibs/case_studies/fragment_reassembly.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2024, The OTNS Authors. +# Copyright (c) 2024-2026, The OTNS Authors. # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -44,7 +44,7 @@ def ping_test(ns, datasz, count): def main(): - ns = OTNS(otns_args=['-seed', '550', '-logfile', 'trace']) + ns = OTNS(otns_args=['-seed', '550', '-log-node', 'trace']) ns.speed = 1e6 ns.radiomodel = 'MutualInterference' #ns.radiomodel = 'MIDisc' diff --git a/pylibs/case_studies/office_floor_multi_runs.py b/pylibs/case_studies/office_floor_multi_runs.py index e93cecc2..89fc04e9 100755 --- a/pylibs/case_studies/office_floor_multi_runs.py +++ b/pylibs/case_studies/office_floor_multi_runs.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2024, The OTNS Authors. +# Copyright (c) 2024-2026, The OTNS Authors. # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -72,7 +72,7 @@ def run_formation(run_id, sim_time): ns.close() shutil.copy('tmp/0_stats.csv', f'office_runs/{run_id_str}.csv') - shutil.copy('current.pcap', f'office_runs/{run_id_str}.pcap') + ns.save_pcap('office_runs', f'{run_id_str}.pcap') def main(): diff --git a/pylibs/case_studies/srp_dataset_types.py b/pylibs/case_studies/srp_dataset_types.py index 10cbb382..edffec51 100755 --- a/pylibs/case_studies/srp_dataset_types.py +++ b/pylibs/case_studies/srp_dataset_types.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2024, The OTNS Authors. +# Copyright (c) 2024-2026, The OTNS Authors. # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -69,7 +69,7 @@ def expect_count(expected_count, lines): def main(): - ns = OTNS(otns_args=['-seed', '84541', '-logfile', 'info']) + ns = OTNS(otns_args=['-seed', '84541', '-log-node', 'info']) ns.speed = 200 ns.web() diff --git a/pylibs/case_studies/srp_reregistration.py b/pylibs/case_studies/srp_reregistration.py index 1074c61d..d1e800b3 100755 --- a/pylibs/case_studies/srp_reregistration.py +++ b/pylibs/case_studies/srp_reregistration.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2024, The OTNS Authors. +# Copyright (c) 2024-2026, The OTNS Authors. # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -70,7 +70,7 @@ def print_services(srv): def main(): - ns = OTNS(otns_args=['-seed', '34541', '-logfile', 'info']) + ns = OTNS(otns_args=['-seed', '34541', '-log-node', 'info']) ns.speed = 200 ns.radiomodel = 'MutualInterference' ns.web() diff --git a/pylibs/examples/farm.py b/pylibs/examples/farm.py index c97996be..2e938993 100755 --- a/pylibs/examples/farm.py +++ b/pylibs/examples/farm.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2020-2024, The OTNS Authors. +# Copyright (c) 2020-2026, The OTNS Authors. # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -52,7 +52,7 @@ def main(): - ns = OTNS(otns_args=['-logfile', 'none']) + ns = OTNS(otns_args=['-log-node', 'none']) if False: # Optional forcing of random-seed for OTNS and Python. This gives exact reproducable simulation. # The pcap parameter is to select another PCAP type that includes channel info. diff --git a/pylibs/examples/form_partition.py b/pylibs/examples/form_partition.py index aff1885a..1d4ac048 100755 --- a/pylibs/examples/form_partition.py +++ b/pylibs/examples/form_partition.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2020-2023, The OTNS Authors. +# Copyright (c) 2020-2026, The OTNS Authors. # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -36,7 +36,7 @@ def main(): - ns = OTNS(otns_args=["-log", "debug", '-logfile', 'none']) + ns = OTNS(otns_args=["-log", "debug", '-log-node', 'none']) ns.set_title("Form Partition Example") ns.web() ns.speed = float('inf') diff --git a/pylibs/examples/many_hops_network.py b/pylibs/examples/many_hops_network.py index c741bc49..98dc8d81 100755 --- a/pylibs/examples/many_hops_network.py +++ b/pylibs/examples/many_hops_network.py @@ -1,5 +1,30 @@ #!/usr/bin/env python3 +# Copyright (c) 2025-2026, The OTNS Authors. +# All rights reserved. # +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + # This script simulates a line topology of Routers with a selected number of hops, # for a range of packet-loss percentages. Log files (.csv) are output that show # the state of each node over time, to validate that Routers don't lose connectivity @@ -49,7 +74,7 @@ def simulate(sim_speed: int = SIM_SPEED, packet_loss_ratio: float = 0.0, output_file: str = 'node_state.csv', key_file: str = 'network_info.txt', - pcap_file: str = 'current.pcap', + pcap_file: str = 'many_hops.pcap', router_count: int = ROUTER_COUNT, sed_count: int = SED_COUNT, sim_period: float = SIM_PERIOD, @@ -140,8 +165,8 @@ def simulate(sim_speed: int = SIM_SPEED, for ip in ips: f.write(f'{s}: {ip}\n') - os.rename('current.pcap', pcap_file) ns.close() + ns.save_pcap('.', pcap_file) def main(): diff --git a/pylibs/examples/simple.py b/pylibs/examples/simple.py index a05bf477..0ba65187 100755 --- a/pylibs/examples/simple.py +++ b/pylibs/examples/simple.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2020-2023, The OTNS Authors. +# Copyright (c) 2020-2026, The OTNS Authors. # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -30,7 +30,7 @@ def main(): - ns = OTNS(otns_args=["-log", "debug", "-logfile", "none"]) + ns = OTNS(otns_args=["-log", "debug", "-log-node", "none"]) ns.set_title("Simple Example") ns.web() diff --git a/pylibs/otns/cli/OTNS.py b/pylibs/otns/cli/OTNS.py index a02b0834..4b5d6f9f 100644 --- a/pylibs/otns/cli/OTNS.py +++ b/pylibs/otns/cli/OTNS.py @@ -67,7 +67,15 @@ def __init__(self, self._otns_path = otns_path or self._detect_otns_path() self._sim_id = sim_id + self._sim_output_path = './tmp' listen_port = str(9000 + sim_id * 10) + # Check if a custom output directory is specified in otns_args + if otns_args: + for i, arg in enumerate(otns_args): + if arg == '-output' and i + 1 < len(otns_args): + self._sim_output_path = otns_args[i + 1] + elif arg.startswith('-output='): + self._sim_output_path = arg.split('=', 1)[1] default_args = [ '-autogo=false', '-web=false', '-speed', str(OTNS.DEFAULT_SIMULATE_SPEED), '-listen', f'localhost:{listen_port}' @@ -140,7 +148,8 @@ def save_pcap(self, fpath, fname) -> None: :param fname: the file name of the .pcap file to save to. """ os.makedirs(fpath, exist_ok=True) - shutil.copy2("current.pcap", os.path.join(fpath, fname)) + pcap_src = os.path.join(self._sim_output_path, f"{self._sim_id}_otns.pcap") + shutil.copy2(pcap_src, os.path.join(fpath, fname)) @property def autogo(self) -> bool: @@ -1314,10 +1323,10 @@ def kpi_save(self, filename: str = None) -> Dict: Save collected OTNS KPI data to a JSON file. @:param filename the name of the file to save to or None for no filename provided (This will save to - the OTNS default file ?_kpi.json) + the OTNS default file in the present output directory, which is _kpi.json) """ if filename is None: - filename = 'tmp/0_kpi.json' # TODO: 0_ only works for default -listen port 9000. + filename = os.path.join(self._sim_output_path, f'{self._sim_id}_kpi.json') self._do_command('kpi save') else: self._do_command(f'kpi save "{filename}"') @@ -1357,7 +1366,7 @@ def get_otns_socket(self) -> str: Get the full path to the OTNS Unix socket that nodes use to connect to the current simulation. The current simulation is identified by the sim_id constructor argument (default 0). """ - return f"/tmp/otns/socket_dispatcher_{self._sim_id}" + return os.path.join(self._sim_output_path, f'socket_{self._sim_id}') @staticmethod def _expect_int(output: List[str]) -> int: diff --git a/pylibs/setup.py b/pylibs/setup.py index 7fcddde6..1d1c4ee2 100755 --- a/pylibs/setup.py +++ b/pylibs/setup.py @@ -29,7 +29,7 @@ setuptools.setup( name="pyOTNS", - version="2.2.0", + version="2.3.0", author="The OTNS Authors", description="Run OTNS2 OpenThread mesh network simulations from Python code", url="https://github.com/openthread/ot-ns", diff --git a/pylibs/stress_tests/BaseStressTest.py b/pylibs/stress_tests/BaseStressTest.py index 8a790350..ca454b65 100644 --- a/pylibs/stress_tests/BaseStressTest.py +++ b/pylibs/stress_tests/BaseStressTest.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (c) 2020-2025, The OTNS Authors. +# Copyright (c) 2020-2026, The OTNS Authors. # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -73,7 +73,7 @@ class BaseStressTest(object, metaclass=StressTestMetaclass): def __init__(self, name, headers, web=True, raw=False, rand_seed=None): self.name = name - self._otns_args = ['-log', 'info', '-logfile', 'none'] # change to ['-log', 'debug'] for more messages + self._otns_args = ['-log', 'info', '-log-node', 'none'] # change to ['-log', 'debug'] for more messages if raw: self._otns_args.append('-ot-script') diff --git a/pylibs/unittests/OTNSTestCase.py b/pylibs/unittests/OTNSTestCase.py index 2432e93f..4bdba12b 100644 --- a/pylibs/unittests/OTNSTestCase.py +++ b/pylibs/unittests/OTNSTestCase.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2020-2024, The OTNS Authors. +# Copyright (c) 2020-2026, The OTNS Authors. # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -29,8 +29,14 @@ import tracemalloc import unittest +from pathlib import Path + from otns.cli import OTNS +# Unit tests always run with base OTNS instance with a fixed SimId and output path. +OTNS_OUTPUT_PATH = 'tmp' +OTNS_SIM_ID = 0 + class OTNSTestCase(unittest.TestCase): @@ -44,11 +50,14 @@ def name(self) -> str: def setUp(self) -> None: logging.info("Setting up for test: %s", self.name()) + # Removing all flash files prevents node state carrying over between tests. + for f in Path(OTNS_OUTPUT_PATH).glob(f'{OTNS_SIM_ID}_*.flash'): + f.unlink(missing_ok=True) self.ns = OTNS(otns_args=['-log', 'debug']) # may add '-watch', 'trace' to see detailed OT node traces. def tearDown(self) -> None: self.ns.close() - self.ns.save_pcap("tmp/unittest_pcap", self.name() + ".pcap") + self.ns.save_pcap(f"{OTNS_OUTPUT_PATH}/unittest_pcap", self.name() + ".pcap") def go(self, duration: float) -> None: """ diff --git a/simulation/node.go b/simulation/node.go index 5e79c095..ee4db1a2 100644 --- a/simulation/node.go +++ b/simulation/node.go @@ -89,7 +89,7 @@ func newNode(s *Simulation, nodeid NodeId, cfg *NodeConfig, dnode *dispatcher.No if !cfg.Restore && !cfg.IsExternal { flashFile := fmt.Sprintf("%s/%d_%d.flash", s.cfg.OutputDir, s.cfg.Id, nodeid) - if err = os.RemoveAll(flashFile); err != nil { + if err = os.Remove(flashFile); err != nil && !os.IsNotExist(err) { err = fmt.Errorf("remove flash file %s failed: %w", flashFile, err) return nil, err } @@ -111,7 +111,10 @@ func newNode(s *Simulation, nodeid NodeId, cfg *NodeConfig, dnode *dispatcher.No seedParam := fmt.Sprintf("%d", cfg.RandomSeed) cmd = exec.CommandContext(context.Background(), cfg.ExecutablePath, strconv.Itoa(nodeid), s.d.GetUnixSocketName(), seedParam) } - cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%d", OtSimulationIdEnv, s.cfg.Id)) + cmd.Env = append(os.Environ(), + fmt.Sprintf("%s=%d", OtSimulationIdEnv, s.cfg.Id), + fmt.Sprintf("%s=%s", OtDataPathEnv, s.cfg.OutputDir), + ) node := &Node{ S: s, @@ -128,7 +131,7 @@ func newNode(s *Simulation, nodeid NodeId, cfg *NodeConfig, dnode *dispatcher.No sendGroupIds: make(map[int]struct{}), } - node.Logger.SetFileLevel(s.cfg.LogFileLevel) + node.Logger.SetFileLevel(s.cfg.LogNodeLevel) node.Logger.Debugf("Node config: type=%s IsMtd=%t IsRouter=%t IsBR=%t RxOffWhenIdle=%t", cfg.Type, cfg.IsMtd, cfg.IsRouter, cfg.IsBorderRouter, cfg.RxOffWhenIdle) node.Logger.Debugf(" exe cmd : %v", cmd) @@ -947,7 +950,7 @@ loop: if len(prefix) == 0 && node.S.cmdRunner.GetNodeContext() != node.Id { prefix = node.String() // lazy init of node-specific prefix } - logger.Println(prefix + line) + logger.Println(prefix+line, true, true) default: break loop } diff --git a/simulation/simulation.go b/simulation/simulation.go index 2a8f8191..4de5dd30 100644 --- a/simulation/simulation.go +++ b/simulation/simulation.go @@ -28,8 +28,6 @@ package simulation import ( "fmt" - "io/fs" - "os" "sort" "time" @@ -83,10 +81,6 @@ func NewSimulation(ctx *progctx.ProgCtx, cfg *Config, dispatcherCfg *dispatcher. s.networkInfo.Real = cfg.Realtime // start the dispatcher for virtual time - if dispatcherCfg == nil { - dispatcherCfg = dispatcher.DefaultConfig() - } - if cfg.Realtime { dispatcherCfg.Speed = 1.0 } else { @@ -98,12 +92,6 @@ func NewSimulation(ctx *progctx.ProgCtx, cfg *Config, dispatcherCfg *dispatcher. s.d = dispatcher.NewDispatcher(s.ctx, dispatcherCfg, s) s.d.SetRadioModel(radiomodel.NewRadioModel(cfg.RadioModel)) s.vis = s.d.GetVisualizer() - if err := s.createTmpDir(); err != nil { - logger.Panicf("creating %s/ directory failed: %+v", cfg.OutputDir, err) - } - if err := s.cleanTmpDir(cfg.Id); err != nil { - logger.Panicf("cleaning %s/ directory files '%d_*.*' failed: %+v", cfg.OutputDir, cfg.Id, err) - } //TODO add a flag to turn on/off the energy analyzer s.energyAnalyser = energy.NewEnergyAnalyser() @@ -555,25 +543,6 @@ func (s *Simulation) GoAtSpeed(duration time.Duration, speed float64) <-chan err return s.d.GoAtSpeed(duration, speed) } -func (s *Simulation) cleanTmpDir(simulationId int) error { - // tmp directory is used by nodes for saving *.flash files. Need to be cleaned when simulation started - err := removeAllFiles(fmt.Sprintf("%s/%d_*.flash", s.cfg.OutputDir, simulationId)) - if err != nil { - return err - } - err = removeAllFiles(fmt.Sprintf("%s/%d_*.log", s.cfg.OutputDir, simulationId)) - return err -} - -func (s *Simulation) createTmpDir() error { - // tmp directory is used by nodes for saving *.flash files. Need to be present when simulation started - err := os.Mkdir(s.cfg.OutputDir, 0775) - if errors.Is(err, fs.ErrExist) { - return nil // ok, already present - } - return err -} - func (s *Simulation) SetTitleInfo(titleInfo visualize.TitleInfo) { s.vis.SetTitle(titleInfo) s.energyAnalyser.SetTitle(titleInfo.Title) diff --git a/simulation/simulation_config.go b/simulation/simulation_config.go index dd9dac2d..2a321736 100644 --- a/simulation/simulation_config.go +++ b/simulation/simulation_config.go @@ -59,7 +59,7 @@ type Config struct { Id int Channel ChannelId LogLevel logger.Level - LogFileLevel logger.Level + LogNodeLevel logger.Level RandomSeed prng.RandomSeed OutputDir string } @@ -81,8 +81,8 @@ func DefaultConfig() *Config { Id: 0, Channel: DefaultChannel, LogLevel: logger.WarnLevel, - LogFileLevel: logger.DebugLevel, + LogNodeLevel: logger.DebugLevel, RandomSeed: 0, - OutputDir: "tmp", + OutputDir: "", } } diff --git a/simulation/utils.go b/simulation/utils.go index f7a02f30..41695e69 100644 --- a/simulation/utils.go +++ b/simulation/utils.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2024, The OTNS Authors. +// Copyright (c) 2020-2026, The OTNS Authors. // All rights reserved. // // Redistribution and use in source and binary forms, with or without @@ -30,26 +30,11 @@ import ( "encoding/binary" "math/rand" "net/netip" - "os" - "path/filepath" "strings" "golang.org/x/net/ipv6" ) -func removeAllFiles(globPath string) error { - files, err := filepath.Glob(globPath) - if err != nil { - return err - } - for _, f := range files { - if err := os.Remove(f); err != nil { - return err - } - } - return nil -} - func getCommitFromOtVersion(ver string) string { if strings.HasPrefix(ver, "OPENTHREAD/") && len(ver) >= 13 { commit := ver[11:] diff --git a/types/ot_types.go b/types/ot_types.go index 6033a3b2..31af1e0c 100644 --- a/types/ot_types.go +++ b/types/ot_types.go @@ -30,8 +30,9 @@ package types const ( OtMaxIp6DatagramLength = 1280 - OtMaxUdpPayloadLength = 1232 // this can be adapted - currently not a precise maximum. - OtSimulationIdEnv = "PORT_OFFSET" // the environment var used by OT simulation platforms for simulation ID. + OtMaxUdpPayloadLength = 1232 // this can be adapted - currently not a precise maximum. + OtSimulationIdEnv = "PORT_OFFSET" // environment var used by all OT simulation platforms for simulation ID. + OtDataPathEnv = "OTNS_DATA_PATH" // environment var used by the OT-RFSIM platform for flash file storage and retrieval. ) // OT_ERROR_* error codes from OpenThread that can be sent by OT-NS to the OT nodes.