// 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" "hamlog/internal/db" ) // 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 (inbound) ServiceADIF ServiceType = "adif" // text ADIF over UDP (inbound) ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML (inbound) ServiceRemoteCall ServiceType = "remote_call" // plain text callsign (inbound) // Outbound emitters. ServiceDBUpdated ServiceType = "db_updated" // ADIF of each locally-logged QSO (on save) ServicePstFreq ServiceType = "pstrotator_freq" // radio freq (on freq change) ServiceN1MMRadio ServiceType = "n1mm_radioinfo" // N1MM RadioInfo XML: freq+mode (on freq/mode change) // ServiceWSJTLog wraps the same ADIF in a WSJT-X "Logged ADIF" datagram. // Logger32 and others listen on the WSJT-X interface, not for plain text, and // discard a bare ADIF record without a word — so the two cannot be one row. ServiceWSJTLog ServiceType = "wsjt_log" // ServiceCustom is the general case: the operator picks the trigger, writes // the message, and chooses UDP or HTTP. See Trigger and Config.Template. ServiceCustom ServiceType = "custom" ) // Trigger names the moment a custom outbound row fires. type Trigger string const ( TriggerQSOLogged Trigger = "qso_logged" TriggerRotatorGo Trigger = "rotator_goto" TriggerLookupDone Trigger = "lookup_done" // TriggerBandChange is the RIG changing band, not the entry form: a switch // follows the radio, not what is being typed. Rare by nature, so unlike a // frequency trigger it needs no damping. TriggerBandChange Trigger = "band_change" ) // Transport is how a custom row leaves: a UDP datagram or an HTTP GET. type Transport string const ( TransportUDP Transport = "udp" TransportURL Transport = "url" ) // 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"` // Custom rows only (ServiceCustom). Trigger Trigger `json:"trigger"` Template string `json:"template"` // message text with {fields} Transport Transport `json:"transport"` // udp | url URL string `json:"url"` // URL template, when transport is url LineEnd string `json:"line_end"` // "" | lf | crlf — UDP only } // 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, trig, template, transport, url, line_end 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, &c.Trigger, &c.Template, &c.Transport, &c.URL, &c.LineEnd); 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, trig, template, transport, url, line_end) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, c.Direction, c.Name, c.Port, c.ServiceType, mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, c.Trigger, c.Template, c.Transport, c.URL, c.LineEnd) 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 = ?, trig = ?, template = ?, transport = ?, url = ?, line_end = ?, updated_at = ? WHERE id = ?`, c.Direction, c.Name, c.Port, c.ServiceType, mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, c.Trigger, c.Template, c.Transport, c.URL, c.LineEnd, db.NowISO(), 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 }