commit 255137345f98130ddfa5301d190051651fde223d Author: Torsten Harenberg Date: Sun Feb 2 19:19:16 2025 +0100 first commit in new repo diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c33493 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# The PACTOR-TCP-BRIDGE + +logo +by Torsten Harenberg (DL1THM) + +## What is the PACTOR-TCP-BRIDGE (ptb) + +It's a tool that + +- talks via a serial line (USB or Bluetooth) to [PACTOR modems](https://scs-ptc.com/modems.html) made by [SCS](https://scs-ptc.com) using the extended WA8DED hostmode [(see the manual, chapter 10)](https://www.p4dragon.com/download/SCS_Manual_PTC-IIIusb_4.1.pdf) protocol these modems offer +- offers two TCP sockets which can be used by [Pat](https://getpat.io/) (and other tools) + +## What it is used for + +The main purpose is to build a connection between Pat and PACTOR modems. In order to establish a "listen" mode for PACTOR in Pat, +the modem needs to be kept in the WA8DED hostmode. I found it easiest to create a separate program +for that - in the same way, the VARA modem is a separate program. + +On the long run, this tool will replace the current PACTOR driver in Pat. + +## How to use it + +In order to use this tool, you'll need to configure it. + +If you start it for the first time, it will create a default configuration file and will tell you its location: + +```bash + % ./ptb +new Config file /Users/harenber/.config/dl1thm.pactortcpbridge/Config.yaml created. Please edit the Config file and restart the application +``` + +Now edit the file and configure it accordingly: + +```yaml +device: /tmp/ttyUSB0 +baudrate: 9600 +mycall: N0CALL +server_address: 127.0.0.1:8300 +data_address: 127.0.0.1:8301 +cmdline_init: "" +vara_mode: false +``` + +| Variable | Meaning | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------| +| `device` | Path to the serial device where the modem is connected | +| `baudrate` | baud rate of the modem | +| `mycall` | The callsign to be used on HF | +| `server_address` | server socket address for the **commands** | +| `data_address` | server socket address for the **data** | +| `cmdline_init` | extra commands sent to the modem before going into hostmode, separated by semicolons, Ex: `DISP BR 1;DISP A 1;DISP DIMMOFF` | +| `vara_mode` | see the chapter about the VARA mode | + +If you plan to you Pat with the VARA driver, data `data_address` you **must** use a port number one number higher than the `server_address`. + +A matching Pat config using VARA for the example above would look like: + +```yaml +"varahf": { +"addr": "localhost:8300", +"bandwidth": 2300, +"rig": "", +"ptt_ctrl": false +} +``` + +Using PACTOR, the PTT is triggered through the modem, so you need to set `ptt_ctrl` to `false`! If you configure a `rig`, it +needs to be connected in a way that `rigctl` can configure it. Configure your rig through the PACTOR modem +is **not** supported in VARA mode. + +## VARA mode + +The VARA mode translate WA8DED commands into VARA commands and vice versa. See the VARA homepage for the "VARA TNC Commands" +doumentation. + +If enabledm software which is written to interact with the VARA software TNC to work +with PACTOR as well. It was used by the author to use the [VARA driver for Pat](https://github.com/n8jja/Pat-Vara) +with the PACTOR-TCP-Bridge. + +## How to run + +As a rule of thumb, you have to start the PACTOR-TCP-bridge before you want to use +Pat and stop it after you finished using Pat. + +So a typical session works like this + +1. start the PACTOR-TCP-Bridge +2. wait for it to finish configure your modem +3. use Pat (or any other software) +4. quit Pat +5. stop the PACTOR-TCP-Bridge + +The software supports command line options: + +```bash + % ./ptb --help +Usage of ptb - the PACTOR-IP-Bridge: + -c, --configfile string Name of config file (default "Config.yaml") + -d, --daemon Daemon mode (no TUI) + -l, --logfile string Path to the log file (default "/tmp/pactortcpbridge.log") +``` + +which should be rather self-explaining. + +A typical session (without using the daemon mode, see below) looks like this: + +![Screenshot showing a terminal with a session](pics/Screenshot.png) + +- on the upper left you will see all **payload** from and to the PACTOR modem +- on the lower left the **commands** (with VARA translations, if switched on) to and **answers** from the modem +- on the right hand side you'll see a clock, then a mode indicator (showing `VARA` or `TCP`). Furthermore, you'll see two indicator (`TCP CMD` and `TCP DATA`) which will turn green when a client is connected. The 4 counters below show the usage of the buffers. + + +## "Daemon"-mode + +The "daemon"-mode will omit the terminal UI ("TUI") and might be useful if you have no full terminal (or to run the tool with systemd). + +Instead of the UI, you will only see a message saying that you may quit with CTRL-C. + +## Keys + +- **CTRL-C**
Quits the program +- **CTRL-V**
swtiches between the VARA and normal mode + diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..b71c638 --- /dev/null +++ b/TODO.txt @@ -0,0 +1,15 @@ +--Kein leeren Befehl senden-- +-- Keine leere Antwort durch parsen -- +-- VARA Tests -- +-- CLI -- +-- Debug handling -- +-- Muell vom Input ausschliessen? -- +-- kommen error Mmeldungen durch? -> ja -- +stophostmodem(): Timeouts beim lesen vom Modem... (scheint weg zu sein?) +-- Was mache ich mit dem initfile -- +Log sollte auch auf stdout schreiben +-- VARA Mode beim starten -- +-- Goconcurrentqueue los werden -- +-- "daemon" mode: Achtung.. Daten fuer den Screen werden wahrscheinlich noch geschickt. ERLEDIGT -- +-- Vielleicht flags umstellen auf Standard-Flags mit short forms wie hier beschrieben https://www.antoniojgutierrez.com/posts/2021-05-14-short-and-long-options-in-go-flags-pkg/ -- + diff --git a/fifo.go b/fifo.go new file mode 100644 index 0000000..bdc4b1e --- /dev/null +++ b/fifo.go @@ -0,0 +1,241 @@ +package main + +import ( + "context" + "errors" + "sync" +) + +type ByteFIFO struct { + buffer []byte // The underlying byte slice + mutex sync.Mutex // Mutex for thread safety + cond *sync.Cond // Condition variable for signaling + cap int // Capacity of the buffer +} + +// NewByteFIFO creates a new ByteFIFO with the given capacity. +func NewByteFIFO(capacity int) *ByteFIFO { + fifo := &ByteFIFO{ + buffer: make([]byte, 0, capacity), + cap: capacity, + } + fifo.cond = sync.NewCond(&fifo.mutex) // Initialize condition variable + return fifo +} + +// Enqueue adds bytes to the end of the buffer. Returns an error if the buffer is full. +func (f *ByteFIFO) Enqueue(data []byte) error { + f.mutex.Lock() + defer f.mutex.Unlock() + + if len(f.buffer)+len(data) > f.cap { + return errors.New("buffer overflow") + } + + f.buffer = append(f.buffer, data...) + f.cond.Signal() // Notify waiting goroutines that data is available + return nil +} + +// Dequeue removes and returns the first `n` bytes from the buffer (or what's left if n>len(f.buffer)). +// Returns an error if nothing is in the queue +func (f *ByteFIFO) Dequeue(n int) ([]byte, error) { + f.mutex.Lock() + defer f.mutex.Unlock() + + if len(f.buffer) == 0 { + return nil, errors.New("buffer is empty") + } + if n > len(f.buffer) { + n = len(f.buffer) + } + + data := f.buffer[:n] + f.buffer = f.buffer[n:] + return data, nil +} + +// DequeueOrWait removes and returns the first `n` bytes, waiting if the buffer is empty until data is available. +func (f *ByteFIFO) DequeueOrWait(n int) ([]byte, error) { + f.mutex.Lock() + defer f.mutex.Unlock() + + // Wait until enough data is available + for len(f.buffer) < n { + f.cond.Wait() + } + + data := f.buffer[:n] + f.buffer = f.buffer[n:] + return data, nil +} + +// DequeueOrWaitContext removes and returns the first `n` bytes, waiting if necessary until data is available or the context is canceled. +func (f *ByteFIFO) DequeueOrWaitContext(ctx context.Context, n int) ([]byte, error) { + done := make(chan struct{}) + var data []byte + var err error + + go func() { + f.mutex.Lock() + defer f.mutex.Unlock() + defer close(done) + + // Wait until enough data is available + for len(f.buffer) < n { + select { + case <-ctx.Done(): + err = ctx.Err() + return + default: + f.cond.Wait() + } + } + + data = f.buffer[:n] + f.buffer = f.buffer[n:] + }() + + select { + case <-done: + return data, err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// Peek returns the first `n` bytes without removing them from the buffer. Returns an error if there are not enough bytes. +func (f *ByteFIFO) Peek(n int) ([]byte, error) { + f.mutex.Lock() + defer f.mutex.Unlock() + + if n > len(f.buffer) { + return nil, errors.New("not enough data in buffer") + } + + return f.buffer[:n], nil +} + +// Size returns the current number of bytes in the buffer. +func (f *ByteFIFO) GetLen() int { + f.mutex.Lock() + defer f.mutex.Unlock() + + return len(f.buffer) +} + +// Capacity returns the maximum capacity of the buffer. +func (f *ByteFIFO) Capacity() int { + return f.cap +} + +// ***** + +type StringFIFO struct { + buffer []string // The underlying byte slice + mutex sync.Mutex // Mutex for thread safety + cond *sync.Cond // Condition variable for signaling +} + +// NewByteFIFO creates a new ByteFIFO with the given capacity. +func NewStringFIFO() *StringFIFO { + fifo := &StringFIFO{ + buffer: make([]string, 0), + } + fifo.cond = sync.NewCond(&fifo.mutex) // Initialize condition variable + return fifo +} + +// Enqueue adds a string to the end of the buffer. Returns an error if the buffer is full. +func (f *StringFIFO) Enqueue(data string) error { + f.mutex.Lock() + defer f.mutex.Unlock() + + f.buffer = append(f.buffer, data) + f.cond.Signal() // Notify waiting goroutines that data is available + return nil +} + +// Dequeue removes and returns the first `n` bytes from the buffer (or what's left if n>len(f.buffer)). +// Returns an error if nothing is in the queue +func (f *StringFIFO) Dequeue() (string, error) { + f.mutex.Lock() + defer f.mutex.Unlock() + + if len(f.buffer) == 0 { + return "", errors.New("buffer is empty") + } + + data := f.buffer[0] + f.buffer = f.buffer[1:] + return data, nil +} + +// DequeueOrWait removes and returns the first `n` bytes, waiting if the buffer is empty until data is available. +func (f *StringFIFO) DequeueOrWait() (string, error) { + f.mutex.Lock() + defer f.mutex.Unlock() + + // Wait until enough data is available + for len(f.buffer) == 0 { + f.cond.Wait() + } + + data := f.buffer[0] + f.buffer = f.buffer[1:] + return data, nil +} + +// DequeueOrWaitContext removes and returns the first `n` bytes, waiting if necessary until data is available or the context is canceled. +func (f *StringFIFO) DequeueOrWaitContext(ctx context.Context) (string, error) { + done := make(chan struct{}) + var data string + var err error + + go func() { + f.mutex.Lock() + defer f.mutex.Unlock() + defer close(done) + + // Wait until enough data is available + for len(f.buffer) == 0 { + select { + case <-ctx.Done(): + err = ctx.Err() + return + default: + f.cond.Wait() + } + } + + data = f.buffer[0] + f.buffer = f.buffer[1:] + }() + + select { + case <-done: + return data, err + case <-ctx.Done(): + return "", ctx.Err() + } +} + +// Peek returns the first `n` bytes without removing them from the buffer. Returns an error if there are not enough bytes. +func (f *StringFIFO) Peek() (string, error) { + f.mutex.Lock() + defer f.mutex.Unlock() + + if len(f.buffer) == 0 { + return "", errors.New("not enough data in buffer") + } + + return f.buffer[0], nil +} + +// GetLen returns the current number of bytes in the buffer. +func (f *StringFIFO) GetLen() int { + f.mutex.Lock() + defer f.mutex.Unlock() + + return len(f.buffer) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ad1cfb4 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module gotests/pactorjarabridge + +go 1.23 + +require ( + github.com/TwiN/go-color v1.4.1 + github.com/albenik/go-serial/v2 v2.6.1 + github.com/howeyc/crc16 v0.0.0-20171223171357-2b2a61e366a6 + github.com/jroimartin/gocui v0.5.0 + github.com/karanveersp/store v0.0.0-20230714152426-8bd691c2bad8 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/creack/goselect v0.1.2 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/nsf/termbox-go v1.1.1 // indirect + go.uber.org/atomic v1.10.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/sys v0.20.0 // indirect +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..3ba0003 --- /dev/null +++ b/main.go @@ -0,0 +1,192 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "path/filepath" + + //log "github.com/fangdingjun/go-log" + "log" + "os" + "os/signal" + "syscall" + + "github.com/jroimartin/gocui" + "github.com/karanveersp/store" + "gopkg.in/yaml.v2" +) + +const usage = `Usage of ptb - the PACTOR-IP-Bridge: + -c, --configfile string Name of config file (default "Config.yaml") + -d, --daemon Daemon mode (no TUI) + -l, --logfile string Path to the log file +` + +const ( + StatusTCPCmdActive uint8 = 1 << iota //00000001 + StatusTCPDataActive //00000010 +) + +type TCPServer struct { + Protocol chan string + ToPactor *ByteFIFO + FromPactor *ByteFIFO + VARAMode bool + DaemonMode bool + Status uint8 + Command struct { + Cmd *StringFIFO + Response *StringFIFO + } + Data struct { + Data *ByteFIFO + Response *ByteFIFO + } +} + +func NewTCPServer(varamode bool, daemonmode bool) *TCPServer { + return &TCPServer{ + Protocol: make(chan string, 1024), + ToPactor: NewByteFIFO(1024), + FromPactor: NewByteFIFO(1024), + VARAMode: varamode, + DaemonMode: daemonmode, + Status: 0, + Command: struct { + Cmd *StringFIFO + Response *StringFIFO + }{Cmd: NewStringFIFO(), Response: NewStringFIFO()}, + Data: struct { + Data *ByteFIFO + Response *ByteFIFO + }{Data: NewByteFIFO(10240), Response: NewByteFIFO(10240)}, + } +} + +type Userconfig struct { + Device string `yaml:"device"` + Baudrate int `yaml:"baudrate"` + Mycall string `yaml:"mycall"` + ServerAddress string `yaml:"server_address"` + DataAddress string `yaml:"data_address"` + CmdLineInit string `yaml:"cmdline_init"` + StartwithVaraMode bool `yaml:"vara_mode"` +} + +func configmanage(Config *Userconfig, path string) error { + cf := store.GetApplicationDirPath() + string(os.PathSeparator) + path + + _, err := os.Stat(cf) + if err != nil { + log.Println("loadConfig error:", err) + log.Println("Config file not found. Creating new one") + Config = &Userconfig{Device: "/tmp/ttyUSB0", + Baudrate: 9600, + Mycall: "N0CALL", + ServerAddress: "127.0.0.1:8300", + DataAddress: "127.0.0.1:8301", + CmdLineInit: "", + StartwithVaraMode: false} + + if err := store.Save("Config.yaml", Config); err != nil { + log.Println("failed to save the Config file: ", err) + return err + } + return errors.New(fmt.Sprintf("new Config file %s created. Please edit the Config file and restart the application", cf)) + + } + if err := store.Load(path, Config); err != nil { + return err + } + + if Config.Device != "" { + //log.Println("Config loaded:", Config) + return nil + } + return errors.New(fmt.Sprintf("Empty device name in Config file. Please edit the Config file %s and restart the application", cf)) +} + +func init() { + store.Init("dl1thm.pactortcpbridge") + store.Register("ini", yaml.Marshal, yaml.Unmarshal) +} + +var s *TCPServer // s is a pointer to TCPServer, managing communication via channels and FIFO queues for command and data handling. + +func main() { + + tmpfile := filepath.FromSlash(os.TempDir() + "/ptb.log") + + // read command line arguments -- the standard flag module unfortunately doesn't know shorthands + var configfile string + flag.StringVar(&configfile, "configfile", "Config.yaml", "Name of config file") + flag.StringVar(&configfile, "c", "Config.yaml", "Name of config file") + var logfile string + flag.StringVar(&logfile, "logfile", tmpfile, "Name of log file") + flag.StringVar(&logfile, "l", tmpfile, "Name of log file") + var daemonMode bool + flag.BoolVar(&daemonMode, "daemon", false, "Daemon mode (no TUI)") + flag.BoolVar(&daemonMode, "d", false, "Daemon mode (no TUI)") + flag.Usage = func() { fmt.Print(usage) } + + flag.Parse() + + f, err := os.OpenFile(logfile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + log.Fatalf("error opening file: %v", err) + } + defer f.Close() + + log.SetOutput(f) + fmt.Println("logging to " + logfile) + + // read config + var Config Userconfig + //c := store.GetApplicationDirPath() + err = configmanage(&Config, configfile) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + s = NewTCPServer(Config.StartwithVaraMode, daemonMode) + fmt.Println("Initializing PACTOR modem, please wait...") + m, err := OpenModem(Config.Device, Config.Baudrate, Config.Mycall, "", Config.CmdLineInit) + if err != nil { + log.Panicln(err) + } + defer m.Close() + + go tcpCmdServer(&Config) + go tcpDataServer(&Config) + + if !daemonMode { + // Initialize the gocui GUI + g, err := gocui.NewGui(gocui.OutputNormal) + if err != nil { + log.Panicln(err) + } + defer g.Close() + + g.SetManagerFunc(layout) + go protocolUpdate(g) + go pactorUpdate(g) + go statusUpdate(g) + + // Set keybindings + if err := keybindings(g); err != nil { + log.Panicln(err) + } + // Start the main TUI loop + if err := g.MainLoop(); err != nil && err != gocui.ErrQuit { + log.Panicln(err) + } + } else { + // daemon mode: just print a message and wait for CTRL-C + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + fmt.Println("pactortcpbridge running, press ctrl+c to end the process...") + <-done + } +} diff --git a/pics/Screenshot.png b/pics/Screenshot.png new file mode 100644 index 0000000..75414e0 Binary files /dev/null and b/pics/Screenshot.png differ diff --git a/pics/ptb.png b/pics/ptb.png new file mode 100644 index 0000000..48dac65 Binary files /dev/null and b/pics/ptb.png differ diff --git a/ptc.go b/ptc.go new file mode 100644 index 0000000..1ca139e --- /dev/null +++ b/ptc.go @@ -0,0 +1,937 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "github.com/TwiN/go-color" + "github.com/albenik/go-serial/v2" + "log" + "math/bits" + "os" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/howeyc/crc16" +) + +var SCSversion = map[string]string{ + "A": "PTC-II", + "B": "PTC-IIpro", + "C": "PTC-IIe", + "D": "PTC-IIex", + "E": "PTC-IIusb", + "F": "PTC-IInet", + "H": "DR-7800", + "I": "DR-7400", + "K": "DR-7000", + "L": "PTC-IIIusb", + "T": "PTC-IIItrx", +} + +type pflags struct { + exit bool + stopmodem bool + //disconnected chan struct{} + connected chan struct{} + closeWaiting chan struct{} +} + +type pmux struct { + device sync.Mutex + pactor sync.Mutex + write sync.Mutex + read sync.Mutex + close sync.Mutex + bufLen sync.Mutex + // sendbuf sync.Mutex + // recvbuf sync.Mutex +} + +type Modem struct { + devicePath string + + localAddr string + remoteAddr string + + state State + + device *serial.Port + mux pmux + wg sync.WaitGroup + flags pflags + goodChunks int + // recvBuf bytes.Buffer + // cmdBuf chan string + // sendBuf bytes.Buffer + packetcounter bool + chanbusy bool + cmdlineinit string + initfile string + closeOnce sync.Once +} + +const ( + SerialTimeout = 1 + PactorChannel = 4 + MaxSendData = 255 + MaxFrameNotTX = 2 +) + +// ToPactor states +const ( + Closed State = iota + Ready + Connecting + Conntected +) + +type State uint8 + +//var debugMux sync.Mutex + +func debugEnabled() int { + if value, ok := os.LookupEnv("PACTOR_DEBUG"); ok { + level, err := strconv.Atoi(value) + if err == nil { + return level + } + } + return 0 +} + +func writeDebug(message string, level int) { + //debugMux.Lock() + // defer debugMux.Unlock() + if debugEnabled() >= level { + _, file, no, ok := runtime.Caller(1) + if ok { + log.Println(file + "#" + strconv.Itoa(no) + ": " + message) + } else { + log.Println(message) + } + } + return +} + +// OpenModem: Initialise the pactor modem and all variables. Switch the modem into hostmode. +// +// Will abort if modem reports failed link setup, Close() is called or timeout +// has occured (90 seconds) +func OpenModem(path string, baudRate int, myCall string, initfile string, cmdlineinit string) (p *Modem, err error) { + writeDebug("Trying to open "+path+" with "+strconv.Itoa(baudRate)+" baud.", 0) + p = &Modem{ + // Initialise variables + devicePath: path, + + localAddr: myCall, + remoteAddr: "", + + state: Closed, + + device: nil, + flags: pflags{ + exit: false, + stopmodem: false, + connected: make(chan struct{}, 1), + closeWaiting: make(chan struct{}, 1)}, + goodChunks: 0, + // cmdBuf: make(chan string, 0), + cmdlineinit: cmdlineinit, + initfile: initfile, + } + + writeDebug("Initialising pactor modem", 1) + if err := p.checkSerialDevice(); err != nil { + writeDebug(err.Error(), 1) + return nil, err + } + + //Setup serial device + if p.device, err = serial.Open(p.devicePath, serial.WithBaudrate(baudRate), serial.WithReadTimeout(SerialTimeout)); err != nil { + writeDebug(err.Error(), 1) + return nil, err + } + + err = p.init() + if err != nil { + return nil, err + } + // run modem thread + + time.Sleep(3 * time.Second) // give the device a moment to settle, so no commands get lost + return p, nil +} + +func (p *Modem) setState(state State) { + writeDebug("Setting state to: "+strconv.FormatUint(uint64(state), 10), 1) + p.state = state +} + +func (p *Modem) IsClosed() bool { + if p == nil { + return true + } + return p.state == Closed +} + +func (p *Modem) init() (err error) { + writeDebug("Entering PACTOR init", 1) + // Get modem out of CRC hostmode if it is still in it while starting. + //p.stophostmode() + //p._write(string([]byte{0xaa, 0xaa, 0x00, 0x01, 0x05, 0x4a, 0x48, 0x4f, 0x53, 0x54, 0x30, 0xfb, 0x3d})) + //time.Sleep(100 * time.Millisecond) + //_, _, _ = p._read(100) + + if _, _, err = p.writeAndGetResponse("", -1, false, 10240); err != nil { + return err + } + + // Make sure, modem is in main menu. Will respose with "ERROR:" when already in it -> Just discard answer! + _, ans, err := p.writeAndGetResponse("Quit", -1, false, 1024) + if err != nil { + return err + } + + if len(ans) < 2 { + return errors.New("Modem does not react to Quit command. Please re-power your modem") + } + + // Check if we can determine the modem's type + _, ver, err := p.writeAndGetResponse("ver ##", -1, false, 1024) + if err != nil { + return err + } + re, err := regexp.Compile(`\w#1`) + if err != nil { + return errors.New("Cannot read SCS modem version string, did you configure the correct serial device? Error: " + err.Error()) + } + version := strings.ReplaceAll(string(re.Find([]byte(ver))), "#1", "") + modem, exists := SCSversion[version] + + if !exists { + return errors.New("Found a modem type: " + ver + " which this driver doesn't support. Please contact the author.") + } + writeDebug("Found a "+modem+" modem at "+p.devicePath, 0) + writeDebug("Running init commands", 1) + ct := time.Now() + commands := []string{"DD", "RESTART", "MYcall " + p.localAddr, "PTCH " + strconv.Itoa(PactorChannel), + "MAXE 35", "REM 0", "CHOB 0", "PD 1", + "ADDLF 0", "ARX 0", "BELL 0", "BC 0", "BKCHR 25", "CMSG 0", "LFIGNORE 0", "LISTEN 0", "MAIL 0", "REMOTE 0", + "PDTIMER 5", "STATUS 1", + "TONES 4", "MARK 1600", "SPACE 1400", "CWID 0", "CONType 3", "MODE 0", + "DATE " + ct.Format("020106"), "TIME " + ct.Format("150405")} + + //run additional commands provided on the command line with "init" + if p.cmdlineinit != "" { + for _, command := range strings.Split(strings.TrimSuffix(p.cmdlineinit, "\n"), ";") { + commands = append(commands, command) + } + } + + for _, cmd := range commands { + var res string + writeDebug("Sending command to modem: "+cmd, 0) + _, res, err = p.writeAndGetResponse(cmd, -1, false, 1024) + if err != nil { + return err + } + if strings.Contains(res, "ERROR") { + log.Print(`Command "` + cmd + `" not accepted: ` + res) + return fmt.Errorf(`Command "` + cmd + `" not accepted: ` + res) + } else { + l := fmt.Sprintf("%s --> "+color.InGreen("OK\n"), cmd) // print some output + log.Println(l) + fmt.Println(l) + } + } + + // run additional commands stored in the init script file + if p.initfile != "" { + if err = p.runInitScript(p.initfile); err != nil { + return err + } + } + p.flags.stopmodem = false + p.setState(Ready) + go p.modemThread() + //p.flags.exit = false + return nil +} + +// Send additional initialisation command stored in the InitScript +// +// Each command has to be on a new line +func (p *Modem) runInitScript(initScript string) error { + if _, err := os.Stat(initScript); os.IsNotExist(err) { + return fmt.Errorf("ERROR: PTC init script defined but not existent: %s", initScript) + } + + file, err := os.Open(initScript) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + writeDebug("Sending command to modem: "+scanner.Text(), 0) + if _, _, err = p.writeAndGetResponse(scanner.Text(), -1, false, 1024); err != nil { + return err + } + } + + if err := scanner.Err(); err != nil { + return err + } + + return nil +} + +func (p *Modem) modemThread() { + writeDebug("start modem thread", 1) + + _, _, _ = p.read(1024) + _, _, _ = p.writeAndGetResponse("JHOST4", -1, false, 1024) + _, _, _ = p.read(1024) + // need to wait a second for WA8DED mode to "settle" + time.Sleep(time.Second) + const chunkSize = 1024 + p.wg.Add(1) + for !p.flags.stopmodem { + + // TX data + if p.getNumFramesNotTransmitted() < MaxFrameNotTX { + // data := p.getSendData() + data, err := s.Data.Data.Dequeue(256) + if err == nil { + //data := []byte(d.(string)) + writeDebug("Write data ("+strconv.Itoa(len(data))+"): "+hex.EncodeToString(data), 2) + // TODO: Catch errors! + _, _, _ = p.writeAndGetResponse(string(data), PactorChannel, false, chunkSize) + time.Sleep(1000 * time.Millisecond) // wait for the data to be filled into the buffer + + if s.VARAMode { + s.Command.Response.Enqueue(fmt.Sprintf("BUFFER %d", s.Data.Data.GetLen())) + } + + } + } + + // TX commands + cmd, err := s.Command.Cmd.Dequeue() + if err == nil { + writeDebug("Write command ("+strconv.Itoa(len(cmd))+"): "+cmd, 1) + _, ans, err := p.writeAndGetResponse(cmd, PactorChannel, true, chunkSize) + if err != nil { + writeDebug("Error when sending Command: "+err.Error(), 0) + } + if strings.HasPrefix(cmd, "C ") || strings.HasPrefix(cmd, "c ") { + + // in VARA mode we need to know if the connection is outgoing or incoming so mark outgoing connects + p.setState(Connecting) + } + if len(ans) > 2 { + if !s.VARAMode { + s.Command.Response.Enqueue(ans[2:]) + } + } + writeDebug("Answer from modem: "+ans, 1) + // TODO: Catch errors! + } + + // RX + var res []byte + + if channels, err := p.getChannelsWithOutput(); err == nil { + for _, c := range channels { + _, data, _ := p.writeAndGetResponse("G", c, true, chunkSize) + if _, res, err = p.checkResponse(data, c); err != nil { + writeDebug("checkResponse returned: "+err.Error(), 1) + } else { + if res != nil { + writeDebug("response: "+string(res)+"\n"+hex.Dump(res), 3) + switch c { + case PactorChannel: + s.Data.Response.Enqueue(res) + writeDebug("Written to sendbuf", 3) + case 254: //Status update + p.chanbusy = int(res[0])&112 == 112 // See PTC-IIIusb manual "STATUS" command + writeDebug("PACTOR state: "+strconv.FormatInt(int64(res[0]), 2), 2) + default: + writeDebug("Channel "+strconv.Itoa(c)+": "+string(res), 1) + } + } + } + } + } + } + time.Sleep(100 * time.Millisecond) + + _, _, err := p.writeAndGetResponse("JHOST0", PactorChannel, true, chunkSize) + if err != nil { + writeDebug("PACTOR ERROR Could not get modem out of the WA8DED mode: "+err.Error(), 0) + } + + writeDebug("Modemthread is about to end", 1) + + time.Sleep(100 * time.Millisecond) + p.wg.Done() + //p.ModemClose() + writeDebug("exit modem thread", 1) +} + +func (p *Modem) Close() error { + _, file, no, ok := runtime.Caller(1) + if ok { + writeDebug("PACTOR Close called from "+file+"#"+strconv.Itoa(no), 1) + } else { + writeDebug("PACTOR Close called", 1) + } + + var err error + p.closeOnce.Do(func() { err = p.close() }) + return err +} + +func (p *Modem) stophostmode() { + writeDebug("Stopping WA8DED hostmode", 0) + var ok bool + var err error + + // Send JHOST0 in CRC-enhanced WA8DED mode syntax + + for ok == false { + buff := make([]byte, 100) + p.device.Write([]byte{0xaa, 0xaa, 0x00, 0x01, 0x05, 0x4a, 0x48, 0x4f, 0x53, 0x54, 0x30, 0xfb, 0x3d}) + time.Sleep(100 * time.Millisecond) + for { + n, err := p.device.Read(buff) + if err != nil { + log.Fatal(err) + break + } + if n == 0 { + break + } + //fmt.Printf("%v", string(buff[:n])) + } + p.device.Write([]byte("\rRESTART\r")) + time.Sleep(1000 * time.Millisecond) + for { + n, err := p.device.Read(buff) + if err != nil { + log.Fatal(err) + break + } + if n == 0 { + break + } + //fmt.Printf("%v", string(buff[:n])) + } + ok, err = regexp.Match("cmd:", buff) + if err != nil { + writeDebug("Error in stophostmode: "+err.Error(), 0) + } + } +} + +// Wait for transmission to be finished +// +// BLOCKING until either all frames are transmitted and acknowledged or timeouts +// occurs +func (p *Modem) waitTransmissionFinish(t time.Duration) error { + timeout := time.After(t) + tick := time.Tick(500 * time.Millisecond) + for { + notAck := p.getNumFrameNotAck() + notTrans := p.getNumFramesNotTransmitted() + select { + case <-timeout: + if notAck != 0 && notTrans != 0 { + return errors.New("Timeout: " + strconv.Itoa(notAck) + "frames not ack and " + strconv.Itoa(notTrans) + " frames not transmitted") + } else if notAck != 0 { + return errors.New("Timeout: " + strconv.Itoa(notAck) + "frames not ack") + } else { + return errors.New("Timeout: " + strconv.Itoa(notTrans) + " frames not transmitted") + } + + case <-tick: + if notAck == 0 && notTrans == 0 { + return nil + } + } + } +} + +// Close current serial connection. Stop all threads and close all channels. +func (p *Modem) close() (err error) { + + _, file, no, ok := runtime.Caller(1) + if ok { + writeDebug("PACTOR close called from "+file+"#"+strconv.Itoa(no), 1) + } else { + writeDebug("PACTOR close called", 1) + } + + //p.disconnect() + writeDebug("signal modem thread to stop", 1) + p.flags.stopmodem = true + writeDebug("waiting for all threads to exit", 1) + p.wg.Wait() + writeDebug("modem thread stopped", 1) + + //p.hostmodeQuit() + // will not close the serial port as we reuse it + //p.device.Close() + p.stophostmode() + p.setState(Closed) + writeDebug("PACTOR close() finished", 1) + return nil +} + +// Set specified flag (channel) +func setFlag(flag chan struct{}) { + flag <- struct{}{} +} + +// Get number of frames not yet transmitted by the modem +func (p *Modem) getNumFramesNotTransmitted() int { + //return p.channelState.c + return 0 +} + +// Get number of frames not acknowledged by the remote station +func (p *Modem) getNumFrameNotAck() int { + //return p.channelState.d + return 0 +} + +// Check if serial device is still available (e.g still connected) +func (p *Modem) checkSerialDevice() (err error) { + if _, err := os.Stat(p.devicePath); os.IsNotExist(err) { + return fmt.Errorf("ERROR: Device %s does not exist", p.devicePath) + } + return nil +} + +// Poll channel 255 with "G" command to find what channels have output. +// Write them into the channels list. +func (p *Modem) getChannelsWithOutput() (channels []int, err error) { + //Poll Channel 255 to find what channels have output + n, chs, err := p.writeAndGetResponse("G", 255, true, 1024) + if err != nil { + return nil, err + } + + channels = make([]int, 0) + + for i, ch := range []byte(chs)[:n-1] { + if (i == 0 && ch == 255) || (i == 1 && ch == 1) { + continue + } + channels = append(channels, int(ch)-1) + } + + writeDebug("Channels with output: "+fmt.Sprintf("%#v", channels), 2) + + return +} + +// Do some checks on the returned data. +// +// Currently, only connection information (payload) messages are passed on +func (p *Modem) checkResponse(resp string, ch int) (n int, data []byte, err error) { + if len(resp) < 3 { + return 0, nil, fmt.Errorf("No data") + } + + head := []byte(resp[:3]) + payload := []byte(resp[3:]) + length := int(head[2]) + 1 + pl := len(payload) + if int(head[0]) != ch { + writeDebug("WARNING: Returned data does not match polled channel\n"+hex.Dump([]byte(resp)), 1) + return 0, nil, fmt.Errorf("Channel missmatch") + } + if int(head[1]) == 1 { //sucess,message follows + writeDebug("*** SUCCESS: "+string(payload), 0) + } + if int(head[1]) == 2 { + writeDebug("*** ERROR: "+string(payload), 0) + } + if int(head[1]) != 7 && int(head[1]) != 3 { + if !s.VARAMode { + s.Command.Response.Enqueue(fmt.Sprintf("%s\n", payload)) + } + writeDebug("Message from Modem: "+string(payload), 0) + return 0, nil, fmt.Errorf("Not a data response") + } + if int(head[1]) == 3 { //Link status + if !s.VARAMode { + s.Command.Response.Enqueue(fmt.Sprintf("%s\n", payload)) + } + writeDebug("*** LINK STATUS: "+string(payload), 0) + // there is no way to get the remote callsign from the WA8DED data, so we have to parse the link status :( + re, err := regexp.Compile(` CONNECTED to \w{2,16}`) + if err != nil { + writeDebug("Cannot convert connect message to callsign: "+string(payload), 1) + } else { + ans := strings.ReplaceAll(string(re.Find(payload)), " CONNECTED to ", "") + if len(ans) > 2 { //callsign consists of 3+ characters + p.remoteAddr = ans + writeDebug("PACTOR connection to: "+ans, 0) + if s.VARAMode { + if p.state == Connecting { //outgoing connection + s.Command.Response.Enqueue(fmt.Sprintf("CONNECTED %s %s BW", p.localAddr, ans)) + } else { //incoming connection + s.Command.Response.Enqueue(fmt.Sprintf("CONNECTED %s %s BW", ans, p.localAddr)) + } + } + p.setState(Conntected) + return 0, nil, nil + } + } + if strings.Contains(string(payload), "DISCONNECTED") { + writeDebug("PACTOR DISCONNECTED", 0) + p.setState(Ready) + if s.VARAMode { + s.Command.Response.Enqueue("DISCONNECTED") + } + // } + //p.channelState.f = 0 + return 0, nil, nil + } + return 0, nil, fmt.Errorf("Link data") + } + if length != pl { + writeDebug("WARNING: Data length "+strconv.Itoa(pl)+" does not match stated amount "+strconv.Itoa(length)+". After "+strconv.Itoa(p.goodChunks)+" good chunks.", 1) + p.goodChunks = 0 + if pl < length { + // TODO: search for propper go function + for i := 0; i < (length - pl); i++ { + payload = append(payload, 0x00) + } + } else { + payload = payload[:length] + } + } else { + p.goodChunks += 1 + writeDebug("Good chunk", 2) + } + return length, payload, nil +} + +// Write and read response from pactor modem +// +// Can be used in both, normal and hostmode. If used in hostmode, provide +// channel (>=0). If used in normal mode, set channel to -1, isCommand is +// ignored. +func (p *Modem) writeAndGetResponse(msg string, ch int, isCommand bool, chunkSize int) (int, string, error) { + writeDebug("wagr: Channel: "+strconv.Itoa(ch)+"; isCommand: "+strconv.FormatBool(isCommand)+"\n"+hex.Dump([]byte(msg)), 3) + var err error + + var n int + var str string + if ch < 0 { + err = p._write(msg + "\r") + if err != nil { + return 0, "", err + } + time.Sleep(500 * time.Millisecond) + i := 0 + var tmp []byte + for { + n, tmp, err = p._read(chunkSize) + str = string(tmp) + if err == nil { + break + } + i += 1 + if i > 9 { + writeDebug("No successful read after 10 times!", 1) + return 0, "", err + } + } + writeDebug(fmt.Sprintf("response: %s\n%s", str, hex.Dump(tmp)), 2) + return n, str, err + } else { + err = p.writeChannel(msg, ch, isCommand) + if err != nil { + return 0, "", err + } + if msg == "JHOST0" { //looks like the SCS modems do not answer to JHOST0, although the standard defines it + return 0, "", nil + } + time.Sleep(50 * time.Millisecond) + writeDebug("Decode WA8DED", 4) + buf := []byte{} + valid := false + + for valid == false { + if bytes.Compare(buf, []byte{170, 170, 170, 85}) == 0 { // check for re-request #170#170#170#85 + // packet was re-requested!! + writeDebug("Re-Request magic received", 3) + buf = []byte{} //delete the re-request packet + err = p.writeChannel(msg, ch, isCommand) // write command again + if err != nil { + return 0, "", err + } + } + br, b, err := p._read(1) + if err != nil { + writeDebug("ERROR at _read: "+error.Error(err), 1) + } + writeDebug("Len: "+strconv.Itoa(len(buf))+"State: "+string(hex.Dump(buf)+"\n"), 4) + if br > 0 { + //we got some data + buf = append(buf, b...) + if len(buf) > 5 { // otherwise it's no enh. WA8DED: 2 magic bytes, 2 header bytes, 2 CRC bytes + //unstuff (from 3rd byte on) + t := bytes.Replace(buf[2:], []byte{170, 0}, []byte{170}, -1) + buf = append([]byte{0xaa, 0xaa}, t...) + if checkcrc(buf) { + n = len(t) - 2 + str = string(t[:n]) + valid = true + } else { + writeDebug("(still) Invalid checksum", 4) + } + + } + } + } + + } + + p.packetcounter = !(p.packetcounter) + writeDebug("wagr: returning \n"+hex.Dump([]byte(str)), 3) + return n, str, err +} + +// Flush waits for the last frames to be transmitted. +// +// Will throw error if remaining frames could not bet sent within 120s +func (p *Modem) Flush() (err error) { + writeDebug("Flush called", 2) + if err = p.waitTransmissionFinish(120 * time.Second); err != nil { + writeDebug(err.Error(), 2) + } + return +} + +// TxBufferLen returns the number of bytes in the out sendbuf queue. +func (p *Modem) TxBufferLen() int { + writeDebug("TxBufferLen called ("+strconv.Itoa(s.Data.Data.GetLen())+" bytes remaining in sendbuf)", 2) + return s.Data.Data.GetLen() + (p.getNumFramesNotTransmitted() * MaxSendData) +} + +func (p *Modem) Busy() bool { + return p.chanbusy +} + +// Write to serial connection (thread safe) +// +// No other read/write operation allowed during this time +func (p *Modem) write(cmd string) error { + p.mux.pactor.Lock() + defer p.mux.pactor.Unlock() + return p._write(cmd) +} + +// Write to serial connection (NOT thread safe) +// +// If used, make shure to lock/unlock p.mux.pactor mutex! +func (p *Modem) _write(cmd string) error { + if err := p.checkSerialDevice(); err != nil { + writeDebug(err.Error(), 1) + return err + } + + writeDebug("write: \n"+hex.Dump([]byte(cmd)), 3) + + p.mux.device.Lock() + defer p.mux.device.Unlock() + out := cmd + "\r" + for { + status, err := p.device.GetModemStatusBits() + if err != nil { + writeDebug("GetModemStatusBits failed. cmd: "+cmd+" Error: "+err.Error(), 1) + return err + } + if status.CTS { + for { + sent, err := p.device.Write([]byte(out)) + if err == nil { + break + } else { + // log.Errorf("ERROR while sending serial command: %s\n", out) + writeDebug(err.Error(), 2) + out = out[sent:] + } + } + break + } + } + + return nil +} + +// *** Helper functions for the CRC hostmode +// Although these functions are small, I prefer to keep their functionality +// separate. They follow the steps in the SCS documentation + +// helper function: de-hexlify and write to debug channel +func printhex(s string) { + t, _ := hex.DecodeString(s) + writeDebug(string(t), 3) +} + +// helper function: "unstuff" a string +func unstuff(s string) string { + //Expect: the string contains aa aa at the beginning, that should NOT be + //stuffed + n, _ := hex.DecodeString(s[4:]) + n = bytes.Replace(n, []byte{170, 0}, []byte{170}, -1) + var r []byte + r = append([]byte{0xaa, 0xaa}, n...) + re := hex.EncodeToString(r) + return re +} + +// helper function: "stuff" a string: replaces every #170 with #170#0 +func stuff(s string) string { + //Expect: the string contains aa aa at the beginning, that should NOT be + //stuffed + n, err := hex.DecodeString(s[4:]) + if err != nil { + writeDebug("ERROR in Stuff: "+err.Error()+"\n"+string(hex.Dump([]byte(s))), 1) + } + + n = bytes.Replace(n, []byte{170}, []byte{170, 0}, -1) + var r []byte + r = append([]byte{0xaa, 0xaa}, n...) + re := hex.EncodeToString(r) + return re +} + +// helper function: calculates the CCITT-CRC16 checksum +func checksum(s string) uint16 { + tochecksum, _ := hex.DecodeString(s[4:]) + chksum := bits.ReverseBytes16(crc16.ChecksumCCITT([]byte(tochecksum))) + return chksum +} + +// helper fuction: check the checksum by comparing +func checkcrc(s []byte) bool { + tochecksum := s[2 : len(s)-2] + chksum := bits.ReverseBytes16(crc16.ChecksumCCITT(tochecksum)) + pksum := s[len(s)-2:] + return (binary.BigEndian.Uint16(pksum) == chksum) +} + +// super helper fuction: convert an ordinary WA8DED message into a CRC-Hostmode message +func docrc(msg string) string { + // step 1: add a #170170 + msg = fmt.Sprintf("%02x%02x%s", 170, 170, msg) + // step 2: calculate and add the checksum + chksum := checksum(msg) + msg = fmt.Sprintf("%s%04x", msg, chksum) + // step 3: add "stuff" bytes + msg = stuff(msg) + return msg + +} + +// Write channel to serial connection (NOT thread safe) +// +// If used, make shure to lock/unlock p.mux.pactor mutex! +func (p *Modem) writeChannel(msg string, ch int, isCommand bool) error { + if err := p.checkSerialDevice(); err != nil { + writeDebug(err.Error(), 1) + return err + } + + var d byte + switch { + case !p.packetcounter && !isCommand: + d = byte(0x00) + case !p.packetcounter && isCommand: + d = byte(0x01) + case p.packetcounter && !isCommand: + d = byte(0x80) + case p.packetcounter && isCommand: + d = byte(0x81) + } + + var o []byte = []byte{170, 170, byte(ch), byte(d), byte((len(msg) - 1))} + o = append(o, []byte(msg)...) + cksum := bits.ReverseBytes16(crc16.ChecksumCCITT(o[2:])) + cksumb := make([]byte, 2) + binary.BigEndian.PutUint16(cksumb, cksum) + o = append(o, cksumb...) + tostuff := o[2:] + tostuff = bytes.Replace(tostuff, []byte{170}, []byte{170, 0}, -1) + o = append([]byte{170, 170}, tostuff...) + /* + msg = fmt.Sprintf("%02x%02x%s", 170, 170, msg) + // step 2: calculate and add the checksum + chksum := checksum(msg) + msg = fmt.Sprintf("%s%04x", msg, chksum) + // step 3: add "stuff" bytes + msg = stuff(msg) + */ + /*c := fmt.Sprintf("%02x", ch) + l := fmt.Sprintf("%02x", (len(msg) - 1)) + s := hex.EncodeToString([]byte(msg)) + // add crc hostmode addons + bs, _ := hex.DecodeString(docrc(fmt.Sprintf("%s%s%s%s", c, d, l, s)))*/ + writeDebug("write channel: \n"+hex.Dump(o), 2) + + if err := p.write(string(o)); err != nil { + writeDebug(err.Error(), 2) + return err + } + /*if !isCommand { + p.cmdBuf <- "%Q" + }*/ + return nil +} + +// Read from serial connection (thread safe) +// +// No other read/write operation allowed during this time +func (p *Modem) read(chunkSize int) (int, []byte, error) { + p.mux.pactor.Lock() + defer p.mux.pactor.Unlock() + return p._read(chunkSize) +} + +// Read from serial connection (NOT thread safe) +// +// If used, make shure to lock/unlock p.mux.pactor mutex! +func (p *Modem) _read(chunkSize int) (int, []byte, error) { + if err := p.checkSerialDevice(); err != nil { + writeDebug(err.Error(), 1) + return 0, []byte{}, err + } + + buf := make([]byte, chunkSize) + + p.mux.device.Lock() + defer p.mux.device.Unlock() + n, err := p.device.Read(buf) + if err != nil { + writeDebug("Error received during read: "+err.Error(), 1) + return 0, []byte{}, err + } + + return n, buf[0:n], nil +} diff --git a/tcpserver.go b/tcpserver.go new file mode 100644 index 0000000..963974f --- /dev/null +++ b/tcpserver.go @@ -0,0 +1,320 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "github.com/TwiN/go-color" + "log" + "net" + "regexp" + "strings" + "time" +) + +// Chunks splits a string into chunks of chunkSize length +// by topskip taken from https://stackoverflow.com/questions/18556693/slice-string-into-letters +func Chunks(s string, chunkSize int) []string { + if len(s) == 0 { + return nil + } + if chunkSize >= len(s) { + return []string{s} + } + var chunks []string = make([]string, 0, (len(s)-1)/chunkSize+1) + currentLen := 0 + currentStart := 0 + for i := range s { + if currentLen == chunkSize { + chunks = append(chunks, s[currentStart:i]) + currentLen = 0 + currentStart = i + } + currentLen++ + } + chunks = append(chunks, s[currentStart:]) + return chunks +} + +// HandleConnection processes incoming connections +func handleTCPCmdConnection(conn net.Conn) { + defer func() { + s.Status &^= StatusTCPCmdActive + conn.Close() + }() + + s.Status |= StatusTCPCmdActive + // Sending "IAMLIVE" every 60 seconds + alivectx, alivecancel := context.WithCancel(context.Background()) + go func(conn net.Conn, ctx context.Context) { + for ctx.Err() == nil { + if s.VARAMode { + s.Command.Response.Enqueue("IAMALIVE") + } + time.Sleep(60 * time.Second) + } + }(conn, alivectx) + defer alivecancel() + + /* + bufferctx, buffercancel := context.WithCancel(context.Background()) + go func(conn net.Conn, ctx context.Context) { + for ctx.Err() == nil { + if s.VARAMode { + s.Command.Response.Enqueue(fmt.Sprintf("BUFFER %d", s.Data.Data.GetLen())) + } + time.Sleep(10 * time.Second) + } + }(conn, bufferctx) + defer buffercancel() + */ + + s.Protocol <- fmt.Sprintf(color.InGreen("TCP Cmd Connection established with %s\n"), conn.RemoteAddr()) + + s.Status |= StatusTCPCmdActive + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func(ctx context.Context) { + for ctx.Err() == nil { + msg, err := s.Command.Response.DequeueOrWaitContext(ctx) + if err != nil { + s.Protocol <- fmt.Sprintf(color.InRed("End of Cmd Data Connection %s\n"), conn.RemoteAddr()) + return + } else { + s.Protocol <- fmt.Sprintf(color.InCyan("Response: %s\n"), msg) + conn.Write([]byte(fmt.Sprintf("%s\r", msg))) + } + } + }(ctx) + + reader := bufio.NewReader(conn) + for { + // Read incoming message + message, err := reader.ReadString('\r') + if err != nil { + //s.Protocol <- fmt.Sprintf(color.InRed("Connection closed by %s\n"), conn.RemoteAddr()) + s.Status &^= StatusTCPCmdActive + alivecancel() + //buffercancel() + cancel() + conn.Close() + break + } + + message = strings.TrimSpace(message) + re := regexp.MustCompile("[[:^ascii:]]") + message = re.ReplaceAllLiteralString(message, "") // prevent any non-ASCII from being sent + + if message == "" { // do not send an empty message to the controller + continue + } + // Process the VARA TNC protocol commands + if s.VARAMode { + translatedmsg, predefanswer, err := processVARACommand(message) + if err != nil { + s.Protocol <- color.InRed(fmt.Sprintf("Error translating VARA command %s: %s\n", message, err.Error())) + log.Println(fmt.Sprintf("Error translating VARA command %s: %s\n", message, err.Error())) + s.Command.Response.Enqueue("WRONG") + } else { + if translatedmsg != "" { + s.Protocol <- fmt.Sprintf(color.InPurple("(V) Got: %s Sending: %s\n"), message, translatedmsg) + s.Command.Cmd.Enqueue(translatedmsg) + } + if predefanswer != "" { + s.Command.Response.Enqueue(predefanswer) + } else { + s.Command.Response.Enqueue("OK") + } + } + } else { // TCP Mode == passthrough + s.Protocol <- fmt.Sprintf(color.InPurple("(T) Sending: %s\n"), message) + s.Command.Cmd.Enqueue(message) + } + + } +} + +func handleTCPDataConnection(conn net.Conn) { + defer func() { + s.Status &^= StatusTCPDataActive + conn.Close() + }() + + s.Protocol <- fmt.Sprintf(color.InGreen("TCP Data Connection established with %s\n"), conn.RemoteAddr()) + s.Status |= StatusTCPDataActive + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + for { + msg, err := s.Data.Response.DequeueOrWaitContext(ctx, 1) + if err != nil { + s.Protocol <- fmt.Sprintf(color.InRed("End of TCP Data Connection %s\n"), conn.RemoteAddr()) + return + } else { + //TODO: VARA + if !s.DaemonMode { + s.FromPactor.Enqueue(msg) + } + conn.Write(msg) + } + } + }() + reader := bufio.NewReader(conn) + for { + var temp []byte + buf := make([]byte, 1024) + n, err := reader.Read(buf) + if n > 0 { + temp = append(temp, buf[:n]...) + } + if err != nil { + s.Status &^= StatusTCPDataActive + cancel() + conn.Close() + return + } + //TODO: muss das nicht weg? + /* if temp == 0x04 { + break + } + */ + if !s.DaemonMode { + s.ToPactor.Enqueue(temp) + } + if n > 255 { + // we can dump at max 255 bytes into + for _, ck := range Chunks(string(temp), 255) { + s.Data.Data.Enqueue([]byte(ck)) + } + } else { + s.Data.Data.Enqueue(temp) + } + } +} + +// Process VARA commands and return appropriate WA8DED command and a pre-defined answer +func processVARACommand(command string) (string, string, error) { + switch { + case strings.HasPrefix(command, "CONNECT"): + re, err := regexp.MatchString(`CONNECT \w+ \w+`, command) + if err != nil || !re { + return "", "", errors.New("Error matching regex") + } + c := strings.Split(command, " ") + return fmt.Sprintf("C %s", c[2]), "", nil + + case strings.HasPrefix(command, "DISCONNECT"): + return "D", "", nil + + case strings.HasPrefix(command, "ABORT"): + return "DD", "", nil + // case strings.HasPrefix(command, "VERSION"): + // return "", nil + + case strings.HasPrefix(command, "LISTEN"): + // Handle listen + if strings.HasSuffix(command, "ON") { + return "%L 1", "", nil + } + if strings.HasSuffix(command, "OFF") { + return "%L 0", "", nil + } + return "", "", errors.New("Neither ON nor OFF after LISTEN") + + case strings.HasPrefix(command, "MYCALL"): + m := "" + // Handle MYCALL + s := strings.Split(command, " ") + if len(s) > 1 { + // send own callsign to PACTOR controller + m = s[1] + } else { + return "", "", errors.New("Invalid MYCALL command") + } + return fmt.Sprintf("%s%s", "I ", m), "", nil + + case strings.HasPrefix(command, "COMPRESSION"): + // Handle COMPRESSION + return "", "", nil + + case strings.HasPrefix(command, "BW"): + // Has no meaning for PACTOR + return "", "", nil + + case strings.HasPrefix(command, "CHAT"): + // Has no meaning for PACTOR + return "", "", nil + + case strings.HasPrefix(command, "WINLINK SESSION"): + // Has no meaning for PACTOR, just return "OK" + return "", "", nil + + case strings.HasPrefix(command, "P2P SESSION"): + // Has no meaning for PACTOR, just return "OK" + return "", "", nil + + case strings.HasPrefix(command, "CWID"): + // Has no meaning for PACTOR, just return "OK" + return "", "", nil + + case strings.HasPrefix(command, "PUBLIC"): + // Has no meaning for PACTOR, just return "OK" + return "", "", nil + + case strings.HasPrefix(command, "VERSION"): + return "", "VERSION 1.0.0", nil + + default: + // Handle unrecognized commands + return "", "", errors.New("Unknown command") + } +} + +func tcpCmdServer(Config *Userconfig) { + // Start listening for connections + listener, err := net.Listen("tcp", Config.ServerAddress) + if err != nil { + s.Protocol <- fmt.Sprintf(color.InWhiteOverRed("Error starting server: %v\n"), err) + log.Println(err) + return + } + defer listener.Close() + + s.Protocol <- fmt.Sprintf("TCP Protocol Server listening on %s\n", Config.ServerAddress) + + for { + // Accept incoming connection + conn, err := listener.Accept() + if err != nil { + s.Protocol <- fmt.Sprintf("Error accepting connection: %v\n", err) + continue + } + + // Handle connection in a separate goroutine + go handleTCPCmdConnection(conn) + } +} + +func tcpDataServer(Config *Userconfig) { + listener, err := net.Listen("tcp", Config.DataAddress) + if err != nil { + s.Protocol <- fmt.Sprintf(color.InWhiteOverRed("Error starting server: %v\n"), err) + log.Println(err) + return + } + defer listener.Close() + + s.Protocol <- fmt.Sprintf("TCP Data Server listening on %s\n", Config.DataAddress) + for { + // Accept incoming connection + conn, err := listener.Accept() + if err != nil { + s.Protocol <- fmt.Sprintf("Error accepting connection: %v\n", err) + continue + } + + // Handle connection in a separate goroutine + go handleTCPDataConnection(conn) + } +} diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..e285050 --- /dev/null +++ b/tui.go @@ -0,0 +1,157 @@ +package main + +import ( + "fmt" + "github.com/TwiN/go-color" + "github.com/jroimartin/gocui" + "log" + "strings" + "time" + "unicode" +) + +func printable(r rune) rune { + if r == rune('\u000d') || r == rune('\u000a') { // ctrl-r or ctrl-n + return rune('\u000a') //ctrl-n + } + if unicode.IsGraphic(r) { + return r + } + return rune('\u002e') // dot +} + +func Ternary(condition bool, valueIfTrue, valueIfFalse interface{}) interface{} { + if condition { + return valueIfTrue + } + return valueIfFalse +} + +func layout(g *gocui.Gui) error { + maxX, maxY := g.Size() + lwX := int(float32(maxX) * 0.8) + + // "data" view: Top-left large view + if v, err := g.SetView("data", 0, 0, lwX-1, maxY/2-1); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = "Data" + v.Wrap = true + v.Autoscroll = true + } + + // "protocol" view: Bottom-left large view + if v, err := g.SetView("protocol", 0, maxY/2, lwX-1, maxY-1); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = "Protocol" + v.Wrap = true + v.Autoscroll = true + } + + // "status" view: Smaller view on the right + if v, err := g.SetView("status", lwX, 0, maxX-1, maxY-1); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = "Status" + } + + return nil +} + +func keybindings(g *gocui.Gui) error { + // Quit the application with Ctrl+C + if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil { + return err + } + if err := g.SetKeybinding("", gocui.KeyCtrlT, gocui.ModNone, toggleMode); err != nil { + return err + } + return nil +} + +func protocolUpdate(g *gocui.Gui) { + for { + msg := <-s.Protocol + g.Update(func(g *gocui.Gui) error { + v, err := g.View("protocol") + if err != nil { + log.Println(err.Error()) + return err + } + fmt.Fprint(v, msg) + return nil + }) + } +} + +func pactorUpdate(g *gocui.Gui) { + for { + if s.ToPactor.GetLen() > 0 { + msg, err := s.ToPactor.Dequeue(256) + if err != nil { + log.Println(err.Error()) + continue + } + g.Update(func(g *gocui.Gui) error { + v, err := g.View("data") + if err != nil { + log.Println(err.Error()) + return err + } + fmt.Fprint(v, strings.Map(printable, string(msg))) + return nil + }) + } + if s.FromPactor.GetLen() > 0 { + msg, err := s.FromPactor.Dequeue(256) + if err != nil { + log.Println(err.Error()) + continue + } + g.Update(func(g *gocui.Gui) error { + v, err := g.View("data") + if err != nil { + log.Println(err.Error()) + return err + } + fmt.Fprint(v, color.InRed(strings.Map(printable, string(msg)))) + return nil + }) + } + time.Sleep(3 * time.Second) + } +} + +func statusUpdate(g *gocui.Gui) { + for { + time.Sleep(1 * time.Second) + g.Update(func(g *gocui.Gui) error { + v, err := g.View("status") + if err != nil { + log.Println(err.Error()) + } else { + v.Clear() + fmt.Fprintln(v, time.Now().Format("15:04:05")) + fmt.Fprintln(v, Ternary(s.VARAMode, "VARA", "TCP")) + fmt.Fprintln(v, Ternary(s.Status&StatusTCPCmdActive != 0, color.InGreen("TCP CMD"), color.InRed("TCP CMD"))) + fmt.Fprintln(v, Ternary(s.Status&StatusTCPDataActive != 0, color.InGreen("TCP DATA"), color.InRed("TCP DATA"))) + fmt.Fprintln(v, fmt.Sprintf("\nCMD S: %d\nCMD R: %d\nDTA S: %d\nDTA R: %d", s.Command.Cmd.GetLen(), s.Command.Response.GetLen(), s.Data.Data.GetLen(), s.Data.Response.GetLen())) + } + return nil + }) + } +} + +func quit(g *gocui.Gui, v *gocui.View) error { + return gocui.ErrQuit +} + +func toggleMode(g *gocui.Gui, v *gocui.View) error { + s.Protocol <- fmt.Sprintf(color.InPurple("Toggle mode\n")) + s.VARAMode = !s.VARAMode + return nil +}