// Package udp manages user-defined UDP integrations: inbound listeners // (WSJT-X, JTDX, MSHV log events; JTAlert ADIF; N1MM XML; DXHunter call) // and outbound emitters (db_updated → notifies Cloudlog/N1MM when HamLog // just logged a QSO). // // One Server per connection row, started/stopped by the Manager when the // user enables/disables or edits the row. Multicast support lets multiple // apps share the same port without bind conflicts — essential since // WSJT-X uses 2237 and several tools already listen there. package udp import ( "context" "database/sql" "fmt" ) // Direction is "inbound" (we listen) or "outbound" (we emit). type Direction string const ( Inbound Direction = "inbound" Outbound Direction = "outbound" ) // ServiceType selects the parser/emitter for a connection. type ServiceType string const ( ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary ServiceADIF ServiceType = "adif" // text ADIF over UDP ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML ServiceRemoteCall ServiceType = "remote_call" // plain text callsign ServiceDBUpdated ServiceType = "db_updated" // outbound ADIF of local QSO ) // Config is one user-defined UDP connection. type Config struct { ID int64 `json:"id"` Direction Direction `json:"direction"` Name string `json:"name"` Port int `json:"port"` ServiceType ServiceType `json:"service_type"` Multicast bool `json:"multicast"` MulticastGroup string `json:"multicast_group"` DestinationIP string `json:"destination_ip"` // outbound only Enabled bool `json:"enabled"` SortOrder int `json:"sort_order"` } // Repo is the persistence layer for UDP integration rows. type Repo struct{ db *sql.DB } func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } func (r *Repo) List(ctx context.Context) ([]Config, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, direction, name, port, service_type, multicast, multicast_group, destination_ip, enabled, sort_order FROM integrations_udp ORDER BY direction, sort_order, id`) if err != nil { return nil, fmt.Errorf("list udp: %w", err) } defer rows.Close() var out []Config for rows.Next() { var c Config var mc, en int if err := rows.Scan(&c.ID, &c.Direction, &c.Name, &c.Port, &c.ServiceType, &mc, &c.MulticastGroup, &c.DestinationIP, &en, &c.SortOrder); err != nil { return nil, err } c.Multicast = mc != 0 c.Enabled = en != 0 out = append(out, c) } return out, rows.Err() } func (r *Repo) Save(ctx context.Context, c *Config) error { if c.Direction != Inbound && c.Direction != Outbound { return fmt.Errorf("invalid direction %q", c.Direction) } if c.Name == "" { return fmt.Errorf("name required") } mc, en := 0, 0 if c.Multicast { mc = 1 } if c.Enabled { en = 1 } if c.ID == 0 { res, err := r.db.ExecContext(ctx, ` INSERT INTO integrations_udp(direction, name, port, service_type, multicast, multicast_group, destination_ip, enabled, sort_order) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, c.Direction, c.Name, c.Port, c.ServiceType, mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder) if err != nil { return fmt.Errorf("insert udp: %w", err) } id, _ := res.LastInsertId() c.ID = id return nil } _, err := r.db.ExecContext(ctx, ` UPDATE integrations_udp SET direction = ?, name = ?, port = ?, service_type = ?, multicast = ?, multicast_group = ?, destination_ip = ?, enabled = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, c.Direction, c.Name, c.Port, c.ServiceType, mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, c.ID) if err != nil { return fmt.Errorf("update udp: %w", err) } return nil } func (r *Repo) Delete(ctx context.Context, id int64) error { _, err := r.db.ExecContext(ctx, `DELETE FROM integrations_udp WHERE id = ?`, id) return err }