src/call.go | 3 +++ src/cmd/nncp/daemon.go | 19 +++++++++++++++++++ src/hfmodem/hfmodem.go | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hfmodem/hfmodem_dummy.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/hfmodem/listener.go | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hfmodem/ptt.go | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hfmodem/ptt_hermes.go | 243 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hfmodem/ptt_hermes_stub.go | 35 +++++++++++++++++++++++++++++++++++ src/hfmodem/vara.go | 270 +++++++++++++++++++++++++++++++++++++++++++++++++++++ diff --git a/src/call.go b/src/call.go index baf656e6441777eac186d83e78735038a7055c1550b8ca9950c439c412d1c750..0d790ae09d7ceecdbafac5695efadb823dce017567d2a482874a70c33b84b922 100644 --- a/src/call.go +++ b/src/call.go @@ -25,6 +25,7 @@ "time" "github.com/dustin/go-humanize" "github.com/gorhill/cronexpr" + nncpHFModem "go.cypherpunks.su/nncp/v8/hfmodem" nncpYggdrasil "go.cypherpunks.su/nncp/v8/yggdrasil" ) @@ -87,6 +88,8 @@ addr = UCSPITCPClient } } else if strings.HasPrefix(addr, "yggdrasilc://") { conn, err = nncpYggdrasil.NewConn(ctx.YggdrasilAliases, addr) + } else if strings.HasPrefix(addr, "vara://") || strings.HasPrefix(addr, "mercury://") { + conn, err = nncpHFModem.NewConn(addr) } else { conn, err = net.Dial("tcp", addr) } diff --git a/src/cmd/nncp/daemon.go b/src/cmd/nncp/daemon.go index 06f5155acdc22669f62f9fedc3d228ce90bef11d08ce0faf1b0dae7ffcedd142..3f6fa54d22a7db70d89414a158a1b9bf3b0ad65e859735b29c4d14fbe978d0e2 100644 --- a/src/cmd/nncp/daemon.go +++ b/src/cmd/nncp/daemon.go @@ -29,6 +29,7 @@ "time" "github.com/dustin/go-humanize" "go.cypherpunks.su/nncp/v8" + nncpHFModem "go.cypherpunks.su/nncp/v8/hfmodem" nncpYggdrasil "go.cypherpunks.su/nncp/v8/yggdrasil" "golang.org/x/net/netutil" ) @@ -137,6 +138,8 @@ ucspi = flag.Bool("ucspi", false, "Is it started as UCSPI-TCP server") inetd = flag.Bool("inetd", false, "Obsolete, use -ucspi") yggdrasil = flag.String("yggdrasil", "", "Start Yggdrasil listener: yggdrasils://PRV[:PORT]?[bind=BIND][&pub=PUB][&peer=PEER][&mcast=REGEX[:PORT]]") + hfmodem = flag.String("hfmodem", "", + "Start HF modem listener: vara://tnc_ip:port/?mycall=CALL&bw=2300") maxConn = flag.Int("maxconn", 128, "Maximal number of simultaneous connections") noCK = flag.Bool("nock", false, "Do no checksum checking") mcdOnce = flag.Bool("mcd-once", false, "Send MCDs once and quit") @@ -286,6 +289,22 @@ for { conn, err := ln.Accept() if err != nil { log.Fatalln("Can not accept connection on Yggdrasil:", err) + } + conns <- conn + } + }() + } + + if *hfmodem != "" { + ln, err := nncpHFModem.NewListener(*hfmodem) + if err != nil { + log.Fatalln("Can not listen on HF modem:", err) + } + go func() { + for { + conn, err := ln.Accept() + if err != nil { + log.Fatalln("Can not accept connection on HF modem:", err) } conns <- conn } diff --git a/src/hfmodem/hfmodem.go b/src/hfmodem/hfmodem.go new file mode 100644 index 0000000000000000000000000000000000000000..2b89b5f48dc91c636ae11f61110f910d7a7041c5d3cfb15510c8047295caa4ba --- /dev/null +++ b/src/hfmodem/hfmodem.go @@ -0,0 +1,247 @@ +// NNCP -- Node to Node copy, utilities for store-and-forward data exchange +// Copyright (C) 2026 Rhizomatica +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build !nohfmodem + +package hfmodem + +import ( + "fmt" + "net" + "net/url" + "os" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + MaxVARABuffer = 8192 + CallsignFile = "/etc/nncp-callsign" +) + +type ModemType int + +const ( + ModemVARA ModemType = iota + ModemMercury // VARA-compatible protocol +) + +// HFConn wraps TCP control+data connections to a TNC into a single +// net.Conn-compatible byte stream for use as an NNCP transport. +type HFConn struct { + ctrlConn net.Conn + dataConn net.Conn + modemType ModemType + localCall string + + mu sync.Mutex + tncBuffer int64 // current TNC buffer level from BUFFER messages + connected bool + + closed int32 // atomic + + writeDeadline time.Time + + ctrlDone chan struct{} // closed when control reader goroutine exits + connectedCh chan struct{} // closed when CONNECTED event received + + pttKeyer PTTKeyer + + remoteAddr string + localAddr string +} + +// hfAddr implements net.Addr for HF modem connections. +type hfAddr struct { + callsign string +} + +func (a hfAddr) Network() string { return "hf" } +func (a hfAddr) String() string { return a.callsign } + +func (c *HFConn) LocalAddr() net.Addr { return hfAddr{c.localAddr} } +func (c *HFConn) RemoteAddr() net.Addr { return hfAddr{c.remoteAddr} } + +func (c *HFConn) Read(p []byte) (int, error) { + if atomic.LoadInt32(&c.closed) != 0 { + return 0, net.ErrClosed + } + return c.dataConn.Read(p) +} + +func (c *HFConn) Write(p []byte) (int, error) { + if atomic.LoadInt32(&c.closed) != 0 { + return 0, net.ErrClosed + } + + // Flow control: block while TNC buffer is too full + for { + c.mu.Lock() + bufLevel := c.tncBuffer + c.mu.Unlock() + + if bufLevel+int64(len(p)) <= MaxVARABuffer { + break + } + + if !c.writeDeadline.IsZero() && time.Now().After(c.writeDeadline) { + return 0, os.ErrDeadlineExceeded + } + + // Check if connection closed before sleeping + select { + case <-c.ctrlDone: + return 0, net.ErrClosed + default: + } + time.Sleep(100 * time.Millisecond) + } + + n, err := c.dataConn.Write(p) + if err == nil { + c.mu.Lock() + c.tncBuffer += int64(n) + c.mu.Unlock() + } + return n, err +} + +func (c *HFConn) Close() error { + if !atomic.CompareAndSwapInt32(&c.closed, 0, 1) { + return nil + } + sendCtrlCmd(c.ctrlConn, "DISCONNECT") + // Wait briefly for DISCONNECTED response + select { + case <-c.ctrlDone: + case <-time.After(5 * time.Second): + } + if c.pttKeyer != nil { + c.pttKeyer.Close() + } + c.dataConn.Close() + return c.ctrlConn.Close() +} + +func (c *HFConn) SetDeadline(t time.Time) error { + c.SetReadDeadline(t) + c.SetWriteDeadline(t) + return nil +} + +func (c *HFConn) SetReadDeadline(t time.Time) error { + return c.dataConn.SetReadDeadline(t) +} + +func (c *HFConn) SetWriteDeadline(t time.Time) error { + c.mu.Lock() + c.writeDeadline = t + c.mu.Unlock() + return nil +} + +// sendCtrlCmd sends a CR-terminated command to the TNC control channel. +func sendCtrlCmd(conn net.Conn, cmd string) error { + _, err := conn.Write([]byte(cmd + "\r")) + return err +} + +// readCallsignFile reads the local callsign from /etc/nncp-callsign. +func readCallsignFile() (string, error) { + data, err := os.ReadFile(CallsignFile) + if err != nil { + return "", fmt.Errorf("reading callsign: %w", err) + } + cs := strings.TrimSpace(string(data)) + if cs == "" { + return "", fmt.Errorf("empty callsign in %s", CallsignFile) + } + return cs, nil +} + +// parseAddr parses an HF modem address URL. +// Format: vara://tnc_ip:control_port/REMOTE_CALL?mycall=X&bw=2300&p2p=true +// +// or: mercury://tnc_ip:control_port/REMOTE_CALL?mycall=X&bw=2300 +type addrConfig struct { + modemType ModemType + tncHost string // ip:control_port + remoteCall string + localCall string + bw string // "500", "2300", "2750" + p2p bool + pttType string // "", "hermes", "hamlib" + pttAddr string // serial path or hamlib host:port +} + +func parseAddr(addr string) (*addrConfig, error) { + u, err := url.Parse(addr) + if err != nil { + return nil, fmt.Errorf("parsing HF address: %w", err) + } + + cfg := &addrConfig{ + tncHost: u.Host, + bw: "2300", + } + + switch u.Scheme { + case "vara": + cfg.modemType = ModemVARA + case "mercury": + cfg.modemType = ModemMercury + default: + return nil, fmt.Errorf("unsupported HF modem scheme: %s", u.Scheme) + } + + cfg.remoteCall = strings.TrimPrefix(u.Path, "/") + + q := u.Query() + if mc := q.Get("mycall"); mc != "" { + cfg.localCall = mc + } else { + cs, err := readCallsignFile() + if err != nil { + return nil, err + } + cfg.localCall = cs + } + + if bw := q.Get("bw"); bw != "" { + cfg.bw = bw + } + if q.Get("p2p") == "true" { + cfg.p2p = true + } + cfg.pttType = q.Get("ptt") + cfg.pttAddr = q.Get("pttaddr") + + return cfg, nil +} + +// NewConn establishes an outbound HF modem connection. +func NewConn(addr string) (net.Conn, error) { + cfg, err := parseAddr(addr) + if err != nil { + return nil, err + } + if cfg.remoteCall == "" { + return nil, fmt.Errorf("remote callsign required for outbound connection") + } + + return varaConnect(cfg) +} diff --git a/src/hfmodem/hfmodem_dummy.go b/src/hfmodem/hfmodem_dummy.go new file mode 100644 index 0000000000000000000000000000000000000000..701b1a1de1ab8112feb1b438d499183417feb4fbae769eb3da2f8eb0d019657c --- /dev/null +++ b/src/hfmodem/hfmodem_dummy.go @@ -0,0 +1,47 @@ +// NNCP -- Node to Node copy, utilities for store-and-forward data exchange +// Copyright (C) 2026 Rhizomatica +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build nohfmodem + +package hfmodem + +import ( + "errors" + "net" +) + +var ErrNoHFModem = errors.New("HF modem support is not compiled in") + +func NewConn(addr string) (net.Conn, error) { + return nil, ErrNoHFModem +} + +type HFListener struct{} + +func NewListener(addr string) (*HFListener, error) { + return nil, ErrNoHFModem +} + +func (l *HFListener) Accept() (net.Conn, error) { + return nil, ErrNoHFModem +} + +func (l *HFListener) Close() error { + return ErrNoHFModem +} + +func (l *HFListener) Addr() net.Addr { + return nil +} diff --git a/src/hfmodem/listener.go b/src/hfmodem/listener.go new file mode 100644 index 0000000000000000000000000000000000000000..a2413c19a5d4ee63eddd203d2a714a971024d71fd64fc7be82c851b572e8b814 --- /dev/null +++ b/src/hfmodem/listener.go @@ -0,0 +1,167 @@ +// NNCP -- Node to Node copy, utilities for store-and-forward data exchange +// Copyright (C) 2026 Rhizomatica +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build !nohfmodem + +package hfmodem + +import ( + "fmt" + "log" + "net" + "sync" + "time" +) + +// HFListener listens for incoming HF modem connections on a VARA/Mercury TNC. +// It connects to the TNC, sends initialization commands (including LISTEN ON), +// and waits for incoming CONNECTED events. Only one connection is active at +// a time (HF radio is single-channel). +type HFListener struct { + cfg *addrConfig + mu sync.Mutex + closed bool + acceptCh chan *HFConn + closeCh chan struct{} +} + +// hfListenerAddr implements net.Addr for the listener. +type hfListenerAddr struct { + addr string +} + +func (a hfListenerAddr) Network() string { return "hf" } +func (a hfListenerAddr) String() string { return a.addr } + +// NewListener creates an HF modem listener. +// addr format: vara://tnc_ip:port/?mycall=CALLSIGN&bw=2300 +// (no remote callsign needed for listening) +func NewListener(addr string) (*HFListener, error) { + cfg, err := parseAddr(addr) + if err != nil { + return nil, err + } + + l := &HFListener{ + cfg: cfg, + acceptCh: make(chan *HFConn), + closeCh: make(chan struct{}), + } + + go l.listenLoop() + return l, nil +} + +// Accept blocks until an incoming HF connection arrives. +func (l *HFListener) Accept() (net.Conn, error) { + select { + case conn, ok := <-l.acceptCh: + if !ok { + return nil, net.ErrClosed + } + return conn, nil + case <-l.closeCh: + return nil, net.ErrClosed + } +} + +// Close shuts down the listener. +func (l *HFListener) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return nil + } + l.closed = true + close(l.closeCh) + return nil +} + +// Addr returns the listener's address. +func (l *HFListener) Addr() net.Addr { + return hfListenerAddr{l.cfg.tncHost} +} + +// listenLoop continuously connects to the TNC and waits for incoming +// connections. When a connection arrives (CONNECTED event), it delivers +// the HFConn via acceptCh, then waits for disconnection before +// re-entering the listening state. +func (l *HFListener) listenLoop() { + defer close(l.acceptCh) + + for { + select { + case <-l.closeCh: + return + default: + } + + conn, err := l.waitForIncoming() + if err != nil { + l.mu.Lock() + closed := l.closed + l.mu.Unlock() + if closed { + return + } + log.Printf("hfmodem: listener error: %v, retrying in 5s", err) + select { + case <-l.closeCh: + return + case <-time.After(5 * time.Second): + } + continue + } + + // Deliver the connection + select { + case l.acceptCh <- conn: + case <-l.closeCh: + conn.Close() + return + } + + // Wait for this connection to end before accepting another + select { + case <-conn.ctrlDone: + case <-l.closeCh: + conn.Close() + return + } + } +} + +// waitForIncoming connects to the TNC, initializes it with LISTEN ON, +// and blocks until a CONNECTED event arrives. +func (l *HFListener) waitForIncoming() (*HFConn, error) { + conn, err := varaDial(l.cfg) + if err != nil { + return nil, fmt.Errorf("connecting to TNC: %w", err) + } + + // Wait for CONNECTED event from the control reader + select { + case <-conn.connectedCh: + return conn, nil + case <-conn.ctrlDone: + conn.ctrlConn.Close() + conn.dataConn.Close() + return nil, fmt.Errorf("TNC control connection lost") + case <-l.closeCh: + conn.ctrlConn.Close() + conn.dataConn.Close() + return nil, net.ErrClosed + } +} diff --git a/src/hfmodem/ptt.go b/src/hfmodem/ptt.go new file mode 100644 index 0000000000000000000000000000000000000000..3dd6399cecc4517e9469468ab1d9066c36ed0fd53eb573721eec3767f03bd74c --- /dev/null +++ b/src/hfmodem/ptt.go @@ -0,0 +1,89 @@ +// NNCP -- Node to Node copy, utilities for store-and-forward data exchange +// Copyright (C) 2026 Rhizomatica +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build !nohfmodem + +package hfmodem + +import ( + "fmt" + "net" + "time" +) + +// PTTKeyer controls push-to-talk on a radio. +type PTTKeyer interface { + KeyOn() error + KeyOff() error + Close() error +} + +// RadioStatusKeyer extends PTTKeyer with radio status updates. +// Implemented by keyers that can display connection status on the radio +// (e.g., Hermes sBitx controller). +type RadioStatusKeyer interface { + PTTKeyer + SetConnected(connected bool) + SetBitrate(bitrate uint32) + SetSNR(snr int32) + SetBytesRx(bytes int32) + SetBytesTx(bytes int32) +} + +// NewPTTKeyer creates a PTT keyer based on the type string. +// Supported types: +// - "hamlib" — connects to rigctld TCP server (addr = "host:port", default "localhost:4532") +// - "hermes" — uses Hermes sBitx radio controller SysV SHM interface (addr ignored) +func NewPTTKeyer(pttType, addr string) (PTTKeyer, error) { + switch pttType { + case "hamlib": + return newHamlibKeyer(addr) + case "hermes": + return newHermesKeyer(addr) + default: + return nil, fmt.Errorf("unsupported PTT type: %s", pttType) + } +} + +// hamlibKeyer controls PTT via hamlib's rigctld TCP protocol. +// Sends "T 1\n" for key on and "T 0\n" for key off. +type hamlibKeyer struct { + conn net.Conn +} + +func newHamlibKeyer(addr string) (*hamlibKeyer, error) { + if addr == "" { + addr = "localhost:4532" + } + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + return nil, fmt.Errorf("connecting to rigctld at %s: %w", addr, err) + } + return &hamlibKeyer{conn: conn}, nil +} + +func (k *hamlibKeyer) KeyOn() error { + _, err := k.conn.Write([]byte("T 1\n")) + return err +} + +func (k *hamlibKeyer) KeyOff() error { + _, err := k.conn.Write([]byte("T 0\n")) + return err +} + +func (k *hamlibKeyer) Close() error { + return k.conn.Close() +} diff --git a/src/hfmodem/ptt_hermes.go b/src/hfmodem/ptt_hermes.go new file mode 100644 index 0000000000000000000000000000000000000000..0a82f716cd73d5ec56f0c70687ef330ba064c29fa5087908ff8c9133309d57f9 --- /dev/null +++ b/src/hfmodem/ptt_hermes.go @@ -0,0 +1,243 @@ +// NNCP -- Node to Node copy, utilities for store-and-forward data exchange +// Copyright (C) 2026 Rhizomatica +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build !nohfmodem && linux + +package hfmodem + +/* +#include +#include +#include +#include +#include +#include +#include +#include + +#define SYSV_SHM_CONTROLLER_KEY_STR 66650 +#define MAX_MESSAGE_SIZE 128 + +typedef struct { + uint8_t service_command[5]; + pthread_mutex_t cmd_mutex; + pthread_cond_t cmd_condition; + + pthread_mutex_t response_mutex; + + uint8_t response_service[5]; + atomic_bool response_available; + + int radio_fd; + + char message[MAX_MESSAGE_SIZE]; + atomic_bool message_available; +} controller_conn; + +static bool radio_cmd(controller_conn *connector, uint8_t *srv_cmd, uint8_t *response) +{ + bool ret_value = false; + + pthread_mutex_lock(&connector->response_mutex); + pthread_mutex_lock(&connector->cmd_mutex); + + memcpy(connector->service_command, srv_cmd, 5); + connector->response_available = false; + + pthread_cond_signal(&connector->cmd_condition); + pthread_mutex_unlock(&connector->cmd_mutex); + + // ~3 ms max wait + uint32_t tries = 0; + uint32_t sleep_time = 100; + while (connector->response_available == false && tries < 30) + { + usleep(sleep_time); + tries++; + if (!(tries % 4)) + sleep_time <<= 1; + } + + if (connector->response_available == true) + { + memcpy(response, connector->response_service, 5); + connector->response_available = false; + ret_value = true; + } + + pthread_mutex_unlock(&connector->response_mutex); + return ret_value; +} + +static controller_conn* shm_attach_controller() +{ + int shmid = shmget(SYSV_SHM_CONTROLLER_KEY_STR, sizeof(controller_conn), 0); + if (shmid == -1) + return NULL; + return (controller_conn *)shmat(shmid, NULL, 0); +} + +// Command codes from radio_cmds.h +#define CMD_PTT_ON 0x10 +#define CMD_PTT_OFF 0x11 +#define CMD_SET_CONNECTED_STATUS 0x15 +#define CMD_SET_BITRATE 0x32 +#define CMD_SET_SNR 0x34 +#define CMD_SET_BYTES_RX 0x36 +#define CMD_SET_BYTES_TX 0x38 + +static bool hermes_ptt_on(controller_conn *conn) { + uint8_t cmd[5] = {0, 0, 0, 0, CMD_PTT_ON}; + uint8_t resp[5]; + return radio_cmd(conn, cmd, resp); +} + +static bool hermes_ptt_off(controller_conn *conn) { + uint8_t cmd[5] = {0, 0, 0, 0, CMD_PTT_OFF}; + uint8_t resp[5]; + return radio_cmd(conn, cmd, resp); +} + +static bool hermes_set_connected(controller_conn *conn, uint8_t status) { + uint8_t cmd[5] = {status, 0, 0, 0, CMD_SET_CONNECTED_STATUS}; + uint8_t resp[5]; + return radio_cmd(conn, cmd, resp); +} + +static bool hermes_set_bitrate(controller_conn *conn, uint32_t bitrate) { + uint8_t cmd[5]; + memcpy(cmd, &bitrate, 4); + cmd[4] = CMD_SET_BITRATE; + uint8_t resp[5]; + return radio_cmd(conn, cmd, resp); +} + +static bool hermes_set_snr(controller_conn *conn, int32_t snr) { + uint8_t cmd[5]; + memcpy(cmd, &snr, 4); + cmd[4] = CMD_SET_SNR; + uint8_t resp[5]; + return radio_cmd(conn, cmd, resp); +} + +static bool hermes_set_bytes_rx(controller_conn *conn, int32_t bytes) { + uint8_t cmd[5]; + memcpy(cmd, &bytes, 4); + cmd[4] = CMD_SET_BYTES_RX; + uint8_t resp[5]; + return radio_cmd(conn, cmd, resp); +} + +static bool hermes_set_bytes_tx(controller_conn *conn, int32_t bytes) { + uint8_t cmd[5]; + memcpy(cmd, &bytes, 4); + cmd[4] = CMD_SET_BYTES_TX; + uint8_t resp[5]; + return radio_cmd(conn, cmd, resp); +} +*/ +import "C" + +import ( + "fmt" + "unsafe" +) + +// hermesKeyer controls PTT and sends status updates via the Hermes +// sBitx radio controller's SysV shared memory interface (key 66650). +type hermesKeyer struct { + conn *C.controller_conn +} + +func newHermesKeyer(_ string) (*hermesKeyer, error) { + conn := C.shm_attach_controller() + if conn == nil { + return nil, fmt.Errorf("hermes: cannot attach to radio controller SHM (key %d)", 66650) + } + return &hermesKeyer{conn: conn}, nil +} + +func (k *hermesKeyer) KeyOn() error { + if k.conn == nil { + return fmt.Errorf("hermes: keyer is closed") + } + if !C.hermes_ptt_on(k.conn) { + return fmt.Errorf("hermes: PTT ON: no response from radio controller") + } + return nil +} + +func (k *hermesKeyer) KeyOff() error { + if k.conn == nil { + return fmt.Errorf("hermes: keyer is closed") + } + if !C.hermes_ptt_off(k.conn) { + return fmt.Errorf("hermes: PTT OFF: no response from radio controller") + } + return nil +} + +func (k *hermesKeyer) Close() error { + if k.conn == nil { + return nil + } + // Set disconnected status and clear stats + C.hermes_set_connected(k.conn, 0) + C.hermes_set_bitrate(k.conn, 0) + C.hermes_set_snr(k.conn, 0) + // Detach SHM + C.shmdt(unsafe.Pointer(k.conn)) + k.conn = nil + return nil +} + +func (k *hermesKeyer) SetConnected(connected bool) { + if k.conn == nil { + return + } + var status C.uint8_t + if connected { + status = 1 + } + C.hermes_set_connected(k.conn, status) +} + +func (k *hermesKeyer) SetBitrate(bitrate uint32) { + if k.conn == nil { + return + } + C.hermes_set_bitrate(k.conn, C.uint32_t(bitrate)) +} + +func (k *hermesKeyer) SetSNR(snr int32) { + if k.conn == nil { + return + } + C.hermes_set_snr(k.conn, C.int32_t(snr)) +} + +func (k *hermesKeyer) SetBytesRx(bytes int32) { + if k.conn == nil { + return + } + C.hermes_set_bytes_rx(k.conn, C.int32_t(bytes)) +} + +func (k *hermesKeyer) SetBytesTx(bytes int32) { + if k.conn == nil { + return + } + C.hermes_set_bytes_tx(k.conn, C.int32_t(bytes)) +} diff --git a/src/hfmodem/ptt_hermes_stub.go b/src/hfmodem/ptt_hermes_stub.go new file mode 100644 index 0000000000000000000000000000000000000000..e3a23a6fc85b3f059dbc496f7d5ce46176c4e1cefd4337bdb5843d5d0213bee5 --- /dev/null +++ b/src/hfmodem/ptt_hermes_stub.go @@ -0,0 +1,35 @@ +// NNCP -- Node to Node copy, utilities for store-and-forward data exchange +// Copyright (C) 2026 Rhizomatica +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build !nohfmodem && !linux + +package hfmodem + +import "fmt" + +func newHermesKeyer(_ string) (*hermesKeyer, error) { + return nil, fmt.Errorf("hermes PTT keyer is only supported on Linux") +} + +type hermesKeyer struct{} + +func (k *hermesKeyer) KeyOn() error { return nil } +func (k *hermesKeyer) KeyOff() error { return nil } +func (k *hermesKeyer) Close() error { return nil } +func (k *hermesKeyer) SetConnected(connected bool) {} +func (k *hermesKeyer) SetBitrate(bitrate uint32) {} +func (k *hermesKeyer) SetSNR(snr int32) {} +func (k *hermesKeyer) SetBytesRx(bytes int32) {} +func (k *hermesKeyer) SetBytesTx(bytes int32) {} diff --git a/src/hfmodem/vara.go b/src/hfmodem/vara.go new file mode 100644 index 0000000000000000000000000000000000000000..55bfda957e4c4f442e8258b657ba2f462b5b9fe8377a1807c4f6a98ae7994540 --- /dev/null +++ b/src/hfmodem/vara.go @@ -0,0 +1,270 @@ +// NNCP -- Node to Node copy, utilities for store-and-forward data exchange +// Copyright (C) 2026 Rhizomatica +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build !nohfmodem + +package hfmodem + +import ( + "bufio" + "fmt" + "log" + "net" + "strconv" + "strings" + "sync/atomic" + "time" +) + +// varaConnect dials a VARA/Mercury TNC, initializes it, and connects to +// a remote station. Returns a net.Conn wrapping the data channel. +func varaConnect(cfg *addrConfig) (*HFConn, error) { + conn, err := varaDial(cfg) + if err != nil { + return nil, err + } + + // Send CONNECT command + cmd := fmt.Sprintf("CONNECT %s %s", conn.localCall, cfg.remoteCall) + if err := sendCtrlCmd(conn.ctrlConn, cmd); err != nil { + conn.ctrlConn.Close() + conn.dataConn.Close() + return nil, fmt.Errorf("sending CONNECT: %w", err) + } + + // Wait for CONNECTED response or failure + select { + case <-conn.connectedCh: + // Successfully connected + case <-conn.ctrlDone: + // Control reader exited (DISCONNECTED or error) before connecting + conn.dataConn.Close() + conn.ctrlConn.Close() + return nil, fmt.Errorf("connection to %s failed", cfg.remoteCall) + case <-time.After(120 * time.Second): + conn.ctrlConn.Close() + conn.dataConn.Close() + return nil, fmt.Errorf("connection to %s timed out", cfg.remoteCall) + } + + return conn, nil +} + +// varaDial connects to TNC TCP ports, sends init commands, and starts +// the control reader goroutine. Does NOT send CONNECT. +func varaDial(cfg *addrConfig) (*HFConn, error) { + // Parse host:port to get control and data ports + host, portStr, err := net.SplitHostPort(cfg.tncHost) + if err != nil { + return nil, fmt.Errorf("parsing TNC address: %w", err) + } + ctrlPort, err := strconv.Atoi(portStr) + if err != nil { + return nil, fmt.Errorf("parsing TNC port: %w", err) + } + dataPort := ctrlPort + 1 + + // Connect to control and data TCP ports + ctrlConn, err := net.DialTimeout("tcp", + fmt.Sprintf("%s:%d", host, ctrlPort), 10*time.Second) + if err != nil { + return nil, fmt.Errorf("connecting to TNC control: %w", err) + } + + dataConn, err := net.DialTimeout("tcp", + fmt.Sprintf("%s:%d", host, dataPort), 10*time.Second) + if err != nil { + ctrlConn.Close() + return nil, fmt.Errorf("connecting to TNC data: %w", err) + } + + var ptt PTTKeyer + if cfg.pttType != "" { + ptt, err = NewPTTKeyer(cfg.pttType, cfg.pttAddr) + if err != nil { + ctrlConn.Close() + dataConn.Close() + return nil, fmt.Errorf("creating PTT keyer: %w", err) + } + } + + conn := &HFConn{ + ctrlConn: ctrlConn, + dataConn: dataConn, + modemType: cfg.modemType, + localCall: cfg.localCall, + remoteAddr: cfg.remoteCall, + localAddr: cfg.localCall, + ctrlDone: make(chan struct{}), + connectedCh: make(chan struct{}), + pttKeyer: ptt, + } + + // Send initialization commands + if err := varaInit(conn, cfg); err != nil { + ctrlConn.Close() + dataConn.Close() + return nil, err + } + + // Start control reader goroutine + go varaControlReader(conn) + + return conn, nil +} + +// varaInit sends the VARA/Mercury initialization command sequence. +func varaInit(conn *HFConn, cfg *addrConfig) error { + cmds := []string{ + fmt.Sprintf("MYCALL %s", conn.localCall), + "LISTEN ON", + "PUBLIC OFF", + "COMPRESSION OFF", + fmt.Sprintf("BW%s", cfg.bw), + } + if cfg.p2p { + cmds = append(cmds, "P2P SESSION") + } + + for _, cmd := range cmds { + if err := sendCtrlCmd(conn.ctrlConn, cmd); err != nil { + return fmt.Errorf("TNC init command %q: %w", cmd, err) + } + time.Sleep(50 * time.Millisecond) + } + return nil +} + +// varaControlReader reads CR-delimited status messages from the TNC +// control channel and updates HFConn state accordingly. +func varaControlReader(conn *HFConn) { + defer close(conn.ctrlDone) + + scanner := bufio.NewScanner(conn.ctrlConn) + scanner.Split(scanCR) + + for scanner.Scan() { + if atomic.LoadInt32(&conn.closed) != 0 { + return + } + + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + switch { + case strings.HasPrefix(line, "CONNECTED"): + conn.mu.Lock() + conn.connected = true + conn.mu.Unlock() + select { + case <-conn.connectedCh: + default: + close(conn.connectedCh) + } + log.Printf("hfmodem: connected: %s", line) + if rsk, ok := conn.pttKeyer.(RadioStatusKeyer); ok { + rsk.SetConnected(true) + } + + case strings.HasPrefix(line, "DISCONNECTED"): + conn.mu.Lock() + conn.connected = false + conn.mu.Unlock() + log.Printf("hfmodem: disconnected") + if rsk, ok := conn.pttKeyer.(RadioStatusKeyer); ok { + rsk.SetConnected(false) + } + return + + case strings.HasPrefix(line, "BUFFER"): + parts := strings.Fields(line) + if len(parts) >= 2 { + if n, err := strconv.ParseInt(parts[1], 10, 64); err == nil { + conn.mu.Lock() + old := conn.tncBuffer + conn.tncBuffer = n + conn.mu.Unlock() + // If buffer drained, data was transmitted + _ = old + } + } + + case strings.HasPrefix(line, "PTT ON"): + if conn.pttKeyer != nil { + if err := conn.pttKeyer.KeyOn(); err != nil { + log.Printf("hfmodem: PTT ON error: %v", err) + } + } + + case strings.HasPrefix(line, "PTT OFF"): + if conn.pttKeyer != nil { + if err := conn.pttKeyer.KeyOff(); err != nil { + log.Printf("hfmodem: PTT OFF error: %v", err) + } + } + + case line == "IAMALIVE": + // Watchdog, ignore + + case strings.HasPrefix(line, "SN"): + log.Printf("hfmodem: %s", line) + if rsk, ok := conn.pttKeyer.(RadioStatusKeyer); ok { + parts := strings.Fields(line) + if len(parts) >= 2 { + if n, err := strconv.ParseInt(parts[1], 10, 32); err == nil { + rsk.SetSNR(int32(n)) + } + } + } + + case strings.HasPrefix(line, "BITRATE"): + log.Printf("hfmodem: %s", line) + if rsk, ok := conn.pttKeyer.(RadioStatusKeyer); ok { + parts := strings.Fields(line) + if len(parts) >= 2 { + if n, err := strconv.ParseUint(parts[1], 10, 32); err == nil { + rsk.SetBitrate(uint32(n)) + } + } + } + + default: + log.Printf("hfmodem: ctrl: %s", line) + } + } + + if err := scanner.Err(); err != nil { + log.Printf("hfmodem: control reader error: %v", err) + } +} + +// scanCR is a bufio.SplitFunc that splits on \r (carriage return), +// matching the VARA/Mercury TNC control protocol delimiter. +func scanCR(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + for i, b := range data { + if b == '\r' { + return i + 1, data[:i], nil + } + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil +}