first commit in new repo
This commit is contained in:
124
README.md
Normal file
124
README.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# The PACTOR-TCP-BRIDGE
|
||||||
|
|
||||||
|
<img src="pics/ptb.png" alt="logo" width="200" style="float: right">
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 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**<br />Quits the program
|
||||||
|
- **CTRL-V**<br />swtiches between the VARA and normal mode
|
||||||
|
|
15
TODO.txt
Normal file
15
TODO.txt
Normal file
@@ -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/ --
|
||||||
|
|
241
fifo.go
Normal file
241
fifo.go
Normal file
@@ -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)
|
||||||
|
}
|
23
go.mod
Normal file
23
go.mod
Normal file
@@ -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
|
||||||
|
)
|
192
main.go
Normal file
192
main.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
BIN
pics/Screenshot.png
Normal file
BIN
pics/Screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 75 KiB |
BIN
pics/ptb.png
Normal file
BIN
pics/ptb.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 61 KiB |
937
ptc.go
Normal file
937
ptc.go
Normal file
@@ -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
|
||||||
|
}
|
320
tcpserver.go
Normal file
320
tcpserver.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
157
tui.go
Normal file
157
tui.go
Normal file
@@ -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
|
||||||
|
}
|
Reference in New Issue
Block a user