From 70ba15d0f280ee51b4dace5c6cfaccf5da6a70eb Mon Sep 17 00:00:00 2001 From: Esko Dijk Date: Sat, 21 Mar 2026 23:57:56 +0100 Subject: [PATCH] [all] all simulation output written to a single configurable directory For running multiple OTNS simulations in sequence, an issue is that presently output files from previous runs may be overwritten. Not all output artefacts are stored in the 'tmp' directory also. This PR ensures that all output files to into a single, configurable directory. The default name is 'tmp' but this can be changed by the commandline argument `-output`. Furthermore, also the OTNS main log which was previously not saved is now stored as a file in this directory. Particular commandline argument parsing changes: -logfile is changed to -log-node which better expresses its purpose: the log level for OT nodes. -noreplay is changed to -replay, with the default being that the replay file is not generated. Typically, this file is not used, so now it needs to be explicitly requested. -log-stdout is added as a way to select whether OTNS log gets written to stdout/stderr too, or only to the OTNS log file. - cross-checks are now being done for mutually incompatible cmd arguments. - improved error display format in case of a user error in using the cmd arguments. For logging to terminal, there's now an improved check for whether stdout is a terminal (accepting ANSI sequences) or not a terminal. The dispatcher socket is also stored in the new output directory. Because this might be a long name, and socket paths are limited, there's a length check added also. Flash files are also stored in the output directory, using an environment var to let OTNS tell the OT node where to store it (OTNS_DATA_PATH). Flash files are not deleted at the start of the simulation anymore, to allow a user to use the 'restore' option to restore a node from a previous simulation based on flash. By default, restore=false and then flash files are deleted (which is the common case). --- cli/runcli.go | 13 +- cmd/otns-replay/otns_replay.go | 4 +- dispatcher/dispatcher.go | 33 ++- dispatcher/dispatcher_config.go | 4 +- energy/core.go | 6 +- go.mod | 4 +- logger/logger.go | 168 +++++++++++----- logger/logger_test.go | 190 ++++++++++++++++++ logger/nodelogger.go | 7 +- ot-rfsim/src/flash.c | 5 +- otns_main/otns_main.go | 73 +++++-- pylibs/case_studies/deprecated_prefix.py | 6 +- pylibs/case_studies/fragment_reassembly.py | 4 +- .../case_studies/office_floor_multi_runs.py | 4 +- pylibs/case_studies/srp_dataset_types.py | 4 +- pylibs/case_studies/srp_reregistration.py | 4 +- pylibs/examples/farm.py | 4 +- pylibs/examples/form_partition.py | 4 +- pylibs/examples/many_hops_network.py | 29 ++- pylibs/examples/simple.py | 4 +- pylibs/otns/cli/OTNS.py | 17 +- pylibs/setup.py | 2 +- pylibs/stress_tests/BaseStressTest.py | 4 +- pylibs/unittests/OTNSTestCase.py | 13 +- simulation/node.go | 11 +- simulation/simulation.go | 31 --- simulation/simulation_config.go | 6 +- simulation/utils.go | 17 +- types/ot_types.go | 5 +- 29 files changed, 500 insertions(+), 176 deletions(-) create mode 100644 logger/logger_test.go 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.