Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d21af9f7c | ||
|
|
0680181016 | ||
|
|
afd0714301 | ||
|
|
8855eaf05a | ||
|
|
2594697e00 | ||
|
|
cf5efea3e4 | ||
|
|
4689505fb9 | ||
|
|
23f425f95f |
@@ -1,77 +1,414 @@
|
||||
# Transceiver Control Interface
|
||||
# OpsLog
|
||||
|
||||
## Introduction
|
||||
<a href="https://discord.gg/tmVPdQ5A8" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||
</a>
|
||||
|
||||
TCI (Transceiver Control Interface) is a network interface for control, data transfer and
|
||||
synchronization between transceiver/receiver, contest loggers, digital mode software, skimmers
|
||||
and other software, as well as external power amplifiers, bandpass filter units, antenna switches,
|
||||
radio controllers and other devices.
|
||||
A modern, fast ham-radio logger for Windows — single-strip entry, real-time CAT
|
||||
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||
DX cluster with spot alerts, awards tracking, maps, contest logging, QSL
|
||||
management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
||||
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
|
||||
optional **shared MySQL** for the logbook so several operators can run one log.
|
||||
Fully themeable and bilingual (English / French).
|
||||
|
||||
TCI was created as a modern alternative to the outdated COM port and audio cable
|
||||
interfaces, it uses a full duplex web socket protocol that runs on top of a TCP connection and
|
||||
serves for server-client communications, providing cross-platform connectivity. Transceiver works
|
||||
as a server, all other software and devices as clients. The server and clients can be inside the
|
||||
same computer (program-server, hardware log, etc.-clients) and/or in separate physical devices
|
||||
connected through the local network (classical transceiver, power amplifier, antenna switch, FFT
|
||||
unit, etc.).
|
||||
Developed by **F4BPO**.
|
||||
|
||||
The TCI interface contains basic transceiver control commands (analog of CAT system),
|
||||
receives CW macros from clients and broadcasts them, outputs transceiver IQ stream to clients,
|
||||
receives spots from skimmers and Internet clusters, receives/outputs audio signal to work in
|
||||
digital modes.
|
||||
---
|
||||
|
||||
The TCI uses an extensible architecture and can be supplemented with new functions and
|
||||
commands, while keeping the old ones operational. Thus, the TCI interface can be extended and
|
||||
supplemented to meet the specific needs of any software manufacturer and/or device
|
||||
manufacturer (receivers, transceivers, power amplifiers, switches, etc.). The presence of a device
|
||||
identifier allows the manufacturers of transceivers and receivers to switch to the TCI interface
|
||||
while maintaining the device model designation. The extensibility of the TCI interface allows you
|
||||
to create an individual set of commands and functions for each device model, while maintaining
|
||||
the basic command set inherent to all transceivers.
|
||||
## Building / developing
|
||||
|
||||
Our company advocates universal unification of data exchange between devices and
|
||||
software by creating the TCI interface for this purpose. Modern transceivers and software must
|
||||
communicate using one protocol - the TCI protocol.
|
||||
- **Dev:** `wails dev` (Vite hot-reload; Go methods reachable at http://localhost:34115).
|
||||
- **Build:** `wails build` (use the project's wails v2.11 — `~/go/bin/wails.exe`).
|
||||
- **Regenerate Go↔TS bindings** after changing exported `App` methods:
|
||||
`wails generate module`.
|
||||
- **Release:** `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task* →
|
||||
*Release OpsLog*) — bumps the version, pushes source to Gitea, builds the exe
|
||||
and publishes it to Gitea + GitHub releases.
|
||||
|
||||
## Interface description
|
||||
---
|
||||
|
||||
Any command represents an ASCII string that contains a command name and a list of
|
||||
arguments corresponding to this command. There are reserved characters that cannot be
|
||||
included in the command name and command arguments.
|
||||
## Logging
|
||||
|
||||
List of reserved characters: «:», «,», «;».
|
||||
- **Single-strip entry:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
||||
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
|
||||
**flag** is shown large next to the RST fields.
|
||||
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||
and the QRZ.com tab.
|
||||
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||
with `/MM` `/AM` `/B` (beacon) and call-area (`/8`, `/W6`) handling, plus
|
||||
ClubLog DXpedition date overrides.
|
||||
- **Editable lists** for bands, modes (with their default RST) and **satellites**
|
||||
— the satellite name on the entry form is offered as a dropdown, because
|
||||
SAT_NAME is compared character for character by the awards and by LoTW.
|
||||
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||
from cty/QRZ/ClubLog, bulk send to QSL services. A live **selection count**, a
|
||||
**Select all / Unselect all** toggle, and a row limit that keeps the full log
|
||||
fast **but is lifted while a filter is active** (every match is shown, not just
|
||||
the first page).
|
||||
- **Advanced QSO filter builder** (field / operator / value, AND / OR, saved
|
||||
presets) with filtered- and selected-row **ADIF export**.
|
||||
- **Find duplicates** (Tools) — groups QSOs by same call + band + mode (optionally
|
||||
same day / minute) and lets you pick which to delete.
|
||||
- **ADIF 3.1.7 compliant** import/export: a full field dictionary, 30 promoted
|
||||
columns, a generic "extra fields" editor and standard/all export modes.
|
||||
- **Profiles:** every setting is per-profile; each profile can point its logbook
|
||||
at the local SQLite file or a **shared MySQL** database (multi-operator).
|
||||
|
||||
Command structure:
|
||||
1. Name of the command;
|
||||
2. Separating character between command name and arguments «:»;
|
||||
3. Separating character between arguments «,»;
|
||||
4. End of the command character «;».
|
||||
## Maps & antenna
|
||||
|
||||
If a command has no arguments, an end of command symbol is placed after the command
|
||||
name. If the command is invalid, it is ignored. The case of letters does not matter.
|
||||
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||
worked-before grid, recent QSOs, the **FT decodes**, or any of the radio
|
||||
consoles — **FlexRadio**, **Icom**, **Yaesu**, **Elecraft K3/K4**, **SunSDR
|
||||
(TCI)** — and the **Net control** panel. Up to four panes, in columns or a
|
||||
quadrant.
|
||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||
basemaps (Light / Topo / Street / Satellite — Esri tiles, key-free and
|
||||
labelled) and the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by
|
||||
**PstRotator** (UDP), a **4O3A Rotator Genius** (native TCP) or a **microHAM
|
||||
ARCO** controlled natively — no PstRotator needed — over the **LAN** or **USB**
|
||||
using its Yaesu GS-232A protocol.
|
||||
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
||||
direction is shown in green and the **mechanical boom** in grey, on both the
|
||||
compass and the map, so you never lose track of where the antenna points.
|
||||
|
||||
The ExpertSDR3 program acts as a server, which can have several client connections at
|
||||
the same time, they will be synchronized with each other by the server. When connecting to the
|
||||
ExpertSDR3, the client receives the current status of the ExpertSDR3, first sending initialization
|
||||
commands, then parameters to set the status, such as frequency, modulation, etc.
|
||||
## DX Cluster
|
||||
|
||||
When a parameter change occurs in the ExpertSDR3 (server) program, the server notifies
|
||||
all connected clients, i.e., clients do not need to poll the server constantly, any change of state
|
||||
will be sent in time to all clients. If the client sends a new state, the server will set it to itself, as
|
||||
well as send it to all clients, that is, the server acts as a synchronizer. All clients connected to the
|
||||
server will be automatically synchronized. This way of work allows to minimize network load,
|
||||
reducing traffic.
|
||||
- Multiple cluster servers with auto-reconnect, a master for commands.
|
||||
- **Filter sidebar** (callsign search, hide-worked, group duplicates, band /
|
||||
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
||||
pane, with a show/hide toggle.
|
||||
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
||||
rig, and a multi-band **Band Map** (panadapter-style strips). Optionally, all
|
||||
**digital modes count as one** (DXCC-style) for the new/new-slot colouring and
|
||||
the worked-before matrix badges (Settings → General).
|
||||
- **POTA** spots are tagged with their park reference (via `api.pota.app`).
|
||||
- **Spot alerts:** rules on call / country / band / mode /
|
||||
spotter, with sound, visual and e-mail notification (Tools → *Alert
|
||||
management*).
|
||||
|
||||
The TCI protocol implements the transmission of receiver IQ stream to clients, which is
|
||||
necessary for the work of special skimmer software, they automatically find the station and
|
||||
decode it throughout the band, and it also allows you to record radio signals in the file.
|
||||
## CAT control
|
||||
|
||||
TCI is also used to transmit audio signals of the receiver to clients and to receive audio
|
||||
signals from clients, i.e., the client can transmit audio signals to ExpertSDR3 for radio
|
||||
transmission. The audio stream exchange is designed to work with digital modes, where encoding
|
||||
and decoding is performed by third-party software, as well as in voice modes, where audio macros
|
||||
can be broadcasted, which is very much in demand in contest loggers.
|
||||
Six native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||
non-blocking connect so a powered-off radio never freezes the app. The settings
|
||||
ask two questions rather than one — **which radio**, then **how it is
|
||||
connected** — and only offer the links that radio actually has:
|
||||
|
||||
When working in contests, it is important to record all on the air operation, for this purpose
|
||||
the audio stream from the line-output is sent to all clients. The resulting audio stream can be
|
||||
recorded to a file or played back with a PC sound card.
|
||||
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||
mode / split, UDP discovery, and **panadapter spots** (cluster spots pushed to
|
||||
the Flex display; a click fills the call and **tunes the rig to the right
|
||||
frequency AND mode**).
|
||||
- **Icom CI-V** — native, over the radio's **USB** port *or* over the internet
|
||||
via the radio's **built-in LAN server** (see *Remote Icom* below). No RS-BA1 or
|
||||
Remote Utility needed.
|
||||
- **Kenwood / Elecraft** — native ASCII CAT over USB or an RS232-to-Ethernet
|
||||
bridge, for the K3 / K4 and the Kenwood dialect they share.
|
||||
- **Yaesu** — native ASCII CAT (FA/FB/MD/VS…), which replaces OmniRig for an
|
||||
FTDX and gives the Yaesu console below.
|
||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR3 and any TCI-compatible server:
|
||||
freq / mode / PTT / split, panorama spots, **audio in both directions** and a
|
||||
full console. See *TCI audio* below.
|
||||
|
||||
**CAT sharing:** OpsLog can hand the radio on to other programs while it holds
|
||||
the port — a **Hamlib NET rigctl** server (WSJT-X, JTDX, MSHV, fldigi…) or a
|
||||
**TCI** server. One or the other, on a port you choose.
|
||||
|
||||
Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred from
|
||||
the frequency. **Per-band Flex RX/TX antennas** can be configured and are applied
|
||||
automatically on band change.
|
||||
|
||||
### FlexRadio control tab (SmartSDR-style)
|
||||
|
||||
Shown only when the CAT backend is a FlexRadio:
|
||||
|
||||
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
||||
VOX (+ level + delay), monitor (+ level), mic gain.
|
||||
- **Receive (active slice):** RX/TX **antenna** selectors and a **DAX** toggle
|
||||
(TX audio through DAX, for WSJT-X & co), AGC mode/threshold, audio level,
|
||||
NB / WNB / NR / ANF, and — on **SmartSDR v4** radios (8000 / Aurora series) —
|
||||
the extra DSP tools **NRL, NRS, NRF** (with level) plus **RNN** (AI noise
|
||||
reduction) and **ANFT** (FFT auto-notch), shown automatically when the radio
|
||||
supports them. **RIT / XIT** with wheel / ± tuning.
|
||||
- **Antenna tuner (ATU):** tune / bypass / memories.
|
||||
- **Amplifier:** the amp card follows whichever amplifier is configured, with a
|
||||
dropdown to pick it when **several amplifiers** are set up (e.g. two SPEs run
|
||||
in parallel). See *Amplifiers & switches* below.
|
||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||
|
||||
### Elecraft K3 / K4 control tab
|
||||
|
||||
Shown for the Elecraft and Kenwood backends: power, AF/RF gain, mic gain,
|
||||
squelch, preamp/attenuator, NB/NR/AGC, filter widths (200 Hz to 4.0 kHz, the
|
||||
K3 maximum and the one FT8 wants), antenna selection, MOX and the ATU
|
||||
(tap to tune, hold for in/out), RIT/XIT with ±10 / ±100 steps, key speed, and
|
||||
live S / power / SWR meters. The SWR is read from the radio's own `SW;`.
|
||||
|
||||
### Yaesu control tab
|
||||
|
||||
Shown for the Yaesu backend: power, mic gain, AGC, NB/NR, preamp/attenuator,
|
||||
filter width, RIT/XIT and the meters — the same shape as the others, driven by
|
||||
the FTDX's own CAT set.
|
||||
|
||||
### SunSDR console (TCI)
|
||||
|
||||
Shown for the TCI backend. Drive and tune drive, mic gain, TUNE, power and SWR
|
||||
while transmitting, volume, mute, squelch and its threshold, NB / NR / ANF,
|
||||
APF in CW, AGC speed, filter widths **offered per mode**, RIT and XIT, and the
|
||||
VFO lock. Levels can be dragged, scrolled, stepped or typed. The S-meter reads
|
||||
real dBm, so its S-units are arithmetic rather than a calibration guess.
|
||||
|
||||
It costs nothing to keep open: TCI **pushes** every setting as it changes —
|
||||
including changes made in the radio's own window, which the console follows
|
||||
without asking anything.
|
||||
|
||||
### Icom control tab
|
||||
|
||||
Shown when the CAT backend is Icom (USB or network). A full RS-BA1-style console:
|
||||
|
||||
- **Twin VFO readout** (MAIN / SUB) with the big tabular frequency, mode badge,
|
||||
band and RIT/ΔTX offset, and a **mode-button row** (SSB / CW / RTTY / PSK /
|
||||
AM / FM).
|
||||
- **Spectrum scope + waterfall** (panadapter): ON/OFF, CTR/FIX, double-click to
|
||||
tune, and **◀ ⊙ ▶** buttons to centre the scope on the current frequency
|
||||
(±50 kHz) and pan left/right.
|
||||
- **Live meters** always visible: S-meter (click → fill RST), power in watts, SWR.
|
||||
- **Receive DSP:** AF / RF gain, squelch, AGC, preamp, attenuator, filter
|
||||
(FIL1/2/3), NB, NR, ANF and — **on CW only** — the **APF** (audio peak filter).
|
||||
- **Passband / notch:** Twin PBT (inner / outer), manual notch + position.
|
||||
- **Transmit:** RF power, MOX, TUNE, **split with an automatic offset**
|
||||
(+5 kHz on SSB, +1 kHz on CW), and monitor. On **voice modes only**: mic gain,
|
||||
speech compressor, VOX (+ gain + anti-VOX). Controls that don't apply to the
|
||||
current mode are hidden automatically.
|
||||
- **Bands & antenna:** one-touch band buttons and ANT1/ANT2 selection.
|
||||
- **Clarifiers:** RIT and ΔTX with wheel / ± tuning (Ctrl+←/→ nudges RIT).
|
||||
- **Power ON / OFF** buttons (manual by design — the app never wakes the rig on
|
||||
connect).
|
||||
- **CW keying** can run through the radio's own keyer (see *Keyers* below).
|
||||
|
||||
### Remote Icom (over the internet, no RS-BA1)
|
||||
|
||||
OpsLog speaks the IC-7610's built-in network protocol directly — it **replaces
|
||||
both the Icom Remote Utility and RS-BA1**. Enter the radio's IP, the Network
|
||||
User1 name/password and the CI-V address, and the whole Icom console works over
|
||||
the LAN/internet: login + token (auto-renewed), CI-V tunnel, receive-side
|
||||
retransmit for a rock-solid link even with the panadapter streaming, and manual
|
||||
power ON/OFF. (Audio is out of scope — use the radio in USB + a voice link such
|
||||
as Mumble.)
|
||||
|
||||
## Keyers & audio
|
||||
|
||||
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
||||
**WinKeyer** (K1EL WK1/2/3 over a COM port), **FlexRadio CWX** (the radio's
|
||||
built-in keyer over the SmartSDR API — type-ahead and backspace, no WinKeyer or
|
||||
SmartCAT needed), **Icom** (the radio's own keyer over CI-V — no extra hardware,
|
||||
works over the remote link too) or **TCI**.
|
||||
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
||||
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||
|
||||
### TCI audio — no virtual cable
|
||||
|
||||
A SunSDR carries its audio on the same WebSocket as its commands, so OpsLog can
|
||||
take it directly. Pick **Radio (TCI network audio)** as the *From radio* or
|
||||
*To radio* device and the QSO recorder and the voice keyer work with no virtual
|
||||
audio cable, no second sound card and nothing to set in the Windows mixer.
|
||||
|
||||
Transmit needs no setting up in ExpertSDR3 either: the audio source is named on
|
||||
each key-down, so it works in SSB as well as the digital modes, and the
|
||||
microphone stays the source for every transmission that is not a voice message.
|
||||
|
||||
## Amplifiers & switches
|
||||
|
||||
- **Amplifiers** — configure **one or several** amps (Settings → Amplifier is a
|
||||
list; e.g. two SPEs run in parallel for more power). Each amp's control card
|
||||
appears on the FlexRadio tab and in **Station Control**, with a dropdown to
|
||||
choose which one it shows; the bottom status bar carries **one clickable chip
|
||||
per amp** (green = OPERATE, orange = STANDBY, red = offline). Supported:
|
||||
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
|
||||
selector and fault display.
|
||||
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the
|
||||
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||
Low / Mid / High output level, an output-power bar and live status (band,
|
||||
SWR, PA current, temperature, warnings/alarms).
|
||||
- **Acom** (500S / 600S / 700S / 1200S / 2020S) over **USB** or the **network**
|
||||
(RS232-to-Ethernet bridge) — operate/standby/off and live telemetry (forward
|
||||
& reflected power, SWR, PA temperature, band, fan, faults). Power-ON works
|
||||
over a serial cable that wires the DTR/RTS lines.
|
||||
- **Elecraft KPA500 / KPA1500** over **USB** (serial) or — on the KPA1500 —
|
||||
over the **network**. Operate/standby, on/off, forward power, SWR,
|
||||
temperature, supply voltage and current, the band, and faults named in plain
|
||||
words rather than as a code. The amplifier follows the radio's band by
|
||||
itself, on the link already open — it has a band command of its own, so no
|
||||
second serial port is involved.
|
||||
|
||||
An amplifier that takes its band from a transceiver instead (Acom, SPE) can be
|
||||
followed a second way: OpsLog answers its frequency polls as a Kenwood rig on
|
||||
a **second COM port**, independent of the control link.
|
||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||
antenna-switch widget, and an option to write the **selected antenna into
|
||||
MY_ANTENNA** under the name the switch itself carries.
|
||||
- **Tuner Genius XL** (4O3A) over TCP — SWR and power, TUNE / BYPASS / OPERATE.
|
||||
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
||||
(CH340 / LCUS, A0 protocol) — for station power, antennas and accessories.
|
||||
- **Relay auto-control** (Settings): switch Station-Control relays automatically
|
||||
from the rig frequency / band (like PstRotator) — per relay, a frequency window
|
||||
or a set of bands.
|
||||
|
||||
## QSL & awards
|
||||
|
||||
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
||||
across profiles) — DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF,
|
||||
**DDFM**, worked/confirmed/validated by band & mode, OR rules and manual
|
||||
reference assignment, live reference detection on call entry, **reference-list
|
||||
import** for totals/names, and a **Rescan** that re-pulls the logbook (picks up
|
||||
fresh LoTW/QRZ confirmations).
|
||||
- **Award statistics** by band and by mode class, with the **DXCC Challenge**
|
||||
worked out where it can be compared to LoTW's own figure — it counts confirmed
|
||||
band-slots on ten bands, and 60 m is not one of them, which is the whole of the
|
||||
usual discrepancy. The band columns follow the bands you actually have contacts
|
||||
on, up to and beyond 23 cm.
|
||||
- **Russian districts (RDA):** an offline district database that knows where a
|
||||
callsign was **on the day of the contact**, a bulk fill for existing QSOs, and
|
||||
a comparison against the district an imported log carries — each disagreement
|
||||
settled on its own row, with the choice written into the contact.
|
||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL,
|
||||
**Cloudlog / Wavelog** and **HAMLOG.online** — upload and **confirmation
|
||||
download** (which auto-refreshes the award stats).
|
||||
- **QSL Card Designer** (see below).
|
||||
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
|
||||
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
||||
password, or a Microsoft app password.)
|
||||
|
||||
## Contest logging
|
||||
|
||||
- **Contest tab:** pick a contest (built-in ADIF `CONTEST_ID` list) and an
|
||||
exchange (running serial or a fixed exchange). OpsLog auto-fills `CONTEST_ID`
|
||||
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
||||
flags dupes and keeps a live scoreboard.
|
||||
|
||||
## Statistics
|
||||
|
||||
- **Logbook statistics dashboard:** headline tiles (QSOs, unique callsigns, DXCC
|
||||
entities, continents, % confirmed) plus charts — QSOs **by mode**, a per-band
|
||||
**CW / phone / data** split, **activity over time** (rolling day / 7-day /
|
||||
30-day / 12-month views), by operator and by continent. Date-range, per-operator
|
||||
and per-contest filters, and a **Table** view that mirrors every chart.
|
||||
|
||||
## Multi-operator live status (special events)
|
||||
|
||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**),
|
||||
publishing is **automatic** — no setting to turn on. Each OpsLog instance
|
||||
heartbeats its current activity (operator call, band, frequency, mode) into a
|
||||
`live_status` table every ~15 s, and drops back to *off air* automatically 5 min
|
||||
after the last logged QSO. A small PHP renderer
|
||||
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
|
||||
own web server reads that table and produces a live page/image you can embed on
|
||||
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
||||
only writes to the DB — it is not a web server.
|
||||
|
||||
## Net control
|
||||
|
||||
- **Directed-net logging** (Tools → Net): a global roster (`nets.json`) plus an
|
||||
in-memory active session — check stations in, then log them individually or the
|
||||
whole net at once (**Log everyone**) using the CAT frequency. **Drag & drop**
|
||||
between the two lists (roster → on-air starts a QSO, on-air → roster logs it),
|
||||
and after each log the next on-air station is selected automatically so you can
|
||||
chain contacts.
|
||||
|
||||
## Appearance & language
|
||||
|
||||
- **Themes:** four complete themes (Warm light, Warm dark, Graphite dark, High
|
||||
contrast) plus **Auto** (follows the OS light/dark preference), selectable in
|
||||
Settings → General. Every panel and every AG-Grid table follows the theme.
|
||||
- **Bilingual:** full **English / French** UI, with a first-run flag chooser and
|
||||
a switcher in Settings → General.
|
||||
|
||||
## Security
|
||||
|
||||
- **Secret vault:** opt-in passphrase encryption of the stored passwords
|
||||
(AES-GCM + PBKDF2). Encrypted values are portable; a single unlock prompt at
|
||||
launch decrypts them for the session.
|
||||
|
||||
## Integrations (outbound)
|
||||
|
||||
- **UDP emitters:** push the current frequency to **PstRotator**, radio info in
|
||||
**N1MM `RadioInfo`** format, or an **ADIF record on each logged QSO** — so
|
||||
external tools (rotator control, digital apps, other loggers) stay in sync.
|
||||
- **CAT sharing:** a **Hamlib NET rigctl** or **TCI** server, so WSJT-X, JTDX,
|
||||
MSHV, fldigi and the rest still reach the radio while OpsLog holds the port.
|
||||
- **WSJT-X / JTDX / MSHV** decodes over UDP: the heard stations feed the band
|
||||
map, the alerts and — on a FlexRadio — the panadapter.
|
||||
|
||||
## Other
|
||||
|
||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||
OpsLog startup, skipping any already running.
|
||||
- **Backup:** optional database + ADIF backup at shutdown.
|
||||
- **Update check** at startup and every 5 minutes (and on opening Help → About),
|
||||
with a toast (toggleable), plus a **What's new** dialog that shows the changelog
|
||||
(English / French) on the first launch after an update — reopenable any time
|
||||
from the Help menu.
|
||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||
|
||||
---
|
||||
|
||||
## QSL Card Designer
|
||||
|
||||
Tools → *QSL Card Designer…* turns a few photos into a polished eQSL card:
|
||||
|
||||
1. Pick 1–6 photos (jpeg/png). OpsLog analyzes them offline (detail/luminance
|
||||
grid) and proposes **3 designs** — callsign in the calmest zone of the best
|
||||
photo, operator name, CQ/ITU zones + locator line, country flag, the other
|
||||
photos as bordered inserts, and a per-QSO confirmation box.
|
||||
2. Pick a proposal and fine-tune it: click an element to select, drag to move,
|
||||
change font / style preset (gel gold, gel silver, classic white outline,
|
||||
script, flat) and per-preset knobs in the right panel.
|
||||
3. Save the template (photos are copied into `data/qsl/templates/<id>/`, so the
|
||||
originals can move). One template can be the default per profile.
|
||||
|
||||
Sending: right-click a QSO → *Send eQSL by e-mail*. The card is rendered with
|
||||
that QSO's data, rasterized to a ≤ 800 KB JPEG, archived in `data/qsl/outbox/`
|
||||
and sent through the configured SMTP account to the address found by the
|
||||
QRZ/HamQTH lookup. On success the QSO is stamped `EQSL_SENT=Y` (ADIF). The
|
||||
e-mail subject/body templates live in the designer
|
||||
(`{CALL} {DATE} {BAND} {MODE} {MYCALL}` variables).
|
||||
|
||||
Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all
|
||||
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
|
||||
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
||||
commonly-worked DXCC entities.
|
||||
|
||||
---
|
||||
|
||||
## Importing a log
|
||||
|
||||
- **ADIF import** with duplicate handling (skip / update / import anyway) and an
|
||||
optional field mapping for exports that use their own names.
|
||||
- Optionally recompute country / continent / DXCC / zones from **cty.dat** and
|
||||
the **ClubLog date-ranged exceptions** — which know that a callsign belonged to
|
||||
a different entity on an older date.
|
||||
- **Deleted DXCC entities are left alone.** cty.dat only knows where a callsign
|
||||
is *today*: R1MVI resolves to European Russia now, but a 2004 contact was Malyj
|
||||
Vysotskij — an entity the ARRL deleted in 2012 and which can never be worked
|
||||
again. Where a record names one of the sixty-odd deleted entities, its own
|
||||
number is kept.
|
||||
|
||||
## Data & storage
|
||||
|
||||
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
|
||||
award lists, QSL templates) always lives in the local SQLite file under
|
||||
`data/` — instant even when the logbook is on a far-away MySQL.
|
||||
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
|
||||
file or a per-profile shared **MySQL** database.
|
||||
|
||||
---
|
||||
|
||||
*A French version of this document is available in [README.fr.md](README.fr.md).*
|
||||
|
||||
@@ -8324,6 +8324,12 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// The saved radio follows what was just edited.
|
||||
//
|
||||
// Without this, an operator with two rigs edits the one on the air, switches
|
||||
// to the other and back, and finds the edit gone — the list would still hold
|
||||
// what that entry looked like when it was created. See app_radios.go.
|
||||
a.syncActiveRadio(s)
|
||||
a.restartAsync("cat", a.reloadCAT)
|
||||
return nil
|
||||
}
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
// Several radios, and the one that is on the air.
|
||||
//
|
||||
// Until now the answer to "I have two rigs" was "make two profiles", which is a
|
||||
// heavy instrument for a light question: a profile carries the whole station —
|
||||
// logbook, callsign, awards, cluster, macros — and switching one to change
|
||||
// which radio is connected takes all of that with it, including a logbook
|
||||
// change if the profiles point at different databases.
|
||||
//
|
||||
// So radios are a LIST, the way amplifiers already are, and switching is one
|
||||
// click on the CAT chip in the status bar. Nothing else about the station
|
||||
// moves.
|
||||
//
|
||||
// The storage deliberately keeps the ACTIVE radio in the settings keys the rest
|
||||
// of OpsLog already reads (cat.backend, cat.icom_port, …). Switching writes the
|
||||
// chosen entry into those keys and reloads the link, so every consumer — the
|
||||
// consoles, the CAT sharing, the band-follow, the panels — sees exactly what it
|
||||
// saw before and needed no change at all. The list is a second store beside it,
|
||||
// not a replacement for it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const (
|
||||
// keyRadiosList holds the saved radios as JSON.
|
||||
keyRadiosList = "cat.radios.json"
|
||||
// keyRadioActive is the id of the one currently connected.
|
||||
keyRadioActive = "cat.radios.active"
|
||||
)
|
||||
|
||||
// RadioConfig is one saved radio: a name and the CAT settings that reach it.
|
||||
type RadioConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// Settings is the whole CAT configuration for this radio — the same shape
|
||||
// the CAT panel has always edited, so a saved radio is exactly "what the
|
||||
// settings said the day it was saved".
|
||||
Settings CATSettings `json:"settings"`
|
||||
}
|
||||
|
||||
// radioLabel is what the status bar shows when the operator never named one.
|
||||
func radioLabel(c RadioConfig, i int) string {
|
||||
if n := strings.TrimSpace(c.Name); n != "" {
|
||||
return n
|
||||
}
|
||||
if b := strings.TrimSpace(c.Settings.Backend); b != "" {
|
||||
return strings.ToUpper(b)
|
||||
}
|
||||
return fmt.Sprintf("Radio %d", i+1)
|
||||
}
|
||||
|
||||
// GetRadios returns the saved radios.
|
||||
//
|
||||
// When nothing was ever saved, the CURRENT settings are presented as the single
|
||||
// entry — so an operator who has been running one rig for a year opens the list
|
||||
// and finds it there, rather than an empty box suggesting their configuration
|
||||
// has been lost. It is persisted on the next save, not here: reading a list
|
||||
// should not write one.
|
||||
func (a *App) GetRadios() ([]RadioConfig, error) {
|
||||
if a.settings == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
raw := a.settingOr(keyRadiosList, "")
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
var list []RadioConfig
|
||||
if err := json.Unmarshal([]byte(raw), &list); err == nil && len(list) > 0 {
|
||||
return list, nil
|
||||
}
|
||||
}
|
||||
cur, err := a.GetCATSettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []RadioConfig{{ID: "radio-1", Name: "", Settings: cur}}, nil
|
||||
}
|
||||
|
||||
// SaveRadios stores the list, giving an id to anything new.
|
||||
func (a *App) SaveRadios(list []RadioConfig) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
for i := range list {
|
||||
if strings.TrimSpace(list[i].ID) == "" {
|
||||
list[i].ID = fmt.Sprintf("radio-%d-%d", time.Now().Unix(), i)
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyRadiosList, string(b))
|
||||
}
|
||||
|
||||
// ActiveRadioID is the id of the radio currently on the air, or the first one
|
||||
// when nothing was ever chosen.
|
||||
func (a *App) ActiveRadioID() string {
|
||||
id := strings.TrimSpace(a.settingOr(keyRadioActive, ""))
|
||||
list, err := a.GetRadios()
|
||||
if err != nil || len(list) == 0 {
|
||||
return id
|
||||
}
|
||||
for _, r := range list {
|
||||
if r.ID == id {
|
||||
return id
|
||||
}
|
||||
}
|
||||
// The stored id names a radio that has since been deleted. The first one is
|
||||
// a better answer than an empty selection: the CAT link is up, and it is up
|
||||
// on SOMETHING.
|
||||
return list[0].ID
|
||||
}
|
||||
|
||||
// RadioListEntry is what the status-bar menu needs: enough to draw a row.
|
||||
//
|
||||
// Name is what the OPERATOR typed, empty when they typed nothing; Label is what
|
||||
// to draw when there is no better idea. The two are separate because the caller
|
||||
// has a better idea than we do: the status bar knows what the radio calls
|
||||
// itself over CAT, and "FTDX10" beats "Radio 2" — but only where the operator
|
||||
// has not given it a name of their own, which beats both.
|
||||
type RadioListEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Backend string `json:"backend"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// ListRadios is the status bar's view of the list.
|
||||
func (a *App) ListRadios() []RadioListEntry {
|
||||
list, err := a.GetRadios()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
active := a.ActiveRadioID()
|
||||
out := make([]RadioListEntry, 0, len(list))
|
||||
for i, r := range list {
|
||||
out = append(out, RadioListEntry{
|
||||
ID: r.ID, Name: strings.TrimSpace(r.Name), Label: radioLabel(r, i),
|
||||
Backend: r.Settings.Backend, Active: r.ID == active,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetActiveRadio connects the given radio and leaves the rest of the station
|
||||
// alone.
|
||||
//
|
||||
// The chosen entry's settings become THE CAT settings — SaveCATSettings writes
|
||||
// them and restarts the link — so switching rigs is the same operation as
|
||||
// editing the CAT panel and pressing Save, which is a path that already works
|
||||
// everywhere it needs to.
|
||||
func (a *App) SetActiveRadio(id string) error {
|
||||
list, err := a.GetRadios()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
for i, r := range list {
|
||||
if r.ID != id {
|
||||
continue
|
||||
}
|
||||
// Persisted BEFORE the link is rebuilt: reloadCAT can take a moment on a
|
||||
// radio that is switched off, and an operator who closes OpsLog during
|
||||
// that moment should still come back to the rig they chose.
|
||||
a.setSetting(keyRadioActive, id)
|
||||
// The list is written back as well when it was only ever implicit, so
|
||||
// the first switch is also what makes the list real.
|
||||
if strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
|
||||
_ = a.SaveRadios(list)
|
||||
}
|
||||
applog.Printf("cat: switching to %q (%s)", radioLabel(r, i), r.Settings.Backend)
|
||||
return a.SaveCATSettings(r.Settings)
|
||||
}
|
||||
return fmt.Errorf("no radio with id %q", id)
|
||||
}
|
||||
|
||||
// syncActiveRadio writes the settings just saved into the active entry of the
|
||||
// list, so the list and the live configuration never disagree.
|
||||
//
|
||||
// Only when a list actually exists: an operator with one radio who has never
|
||||
// opened the list has nothing to keep in step, and writing one here would
|
||||
// create a list as a side effect of saving the CAT panel.
|
||||
func (a *App) syncActiveRadio(s CATSettings) {
|
||||
if a.settings == nil || strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
|
||||
return
|
||||
}
|
||||
list, err := a.GetRadios()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
id := a.ActiveRadioID()
|
||||
for i := range list {
|
||||
if list[i].ID == id {
|
||||
list[i].Settings = s
|
||||
_ = a.SaveRadios(list)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,14 @@
|
||||
[
|
||||
{
|
||||
"version": "0.26.19",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Several radios, and one click to change which one is connected. Settings → CAT holds a list — name them, add one (it starts as a copy of the current one), remove one — and the CAT button in the status bar switches between them. The logbook, the callsign and everything else about the station stay exactly as they are, which is what making a profile per radio could not do."
|
||||
],
|
||||
"fr": [
|
||||
"Plusieurs radios, et un clic pour changer celle qui est connectée. Réglages → CAT contient une liste — les nommer, en ajouter une (elle part d'une copie de la courante), en retirer une — et le bouton CAT de la barre d'état passe de l'une à l'autre. Le carnet, l'indicatif et tout le reste de la station ne bougent pas, ce qu'un profil par radio ne permettait pas."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.18",
|
||||
"date": "",
|
||||
|
||||
+100
-6
@@ -1,7 +1,7 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
||||
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
|
||||
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -290,6 +291,94 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
|
||||
// shortCatError condenses a backend error into a few words for the topbar
|
||||
// pill. The full message stays in the tooltip. Recognises the common cases
|
||||
// (OmniRig not installed, not registered) and otherwise truncates.
|
||||
// RadioChip — the CAT status chip, and the radio picker behind it.
|
||||
function RadioChip({ catUp, catState, onOpenSettings }: {
|
||||
catUp: boolean; catState: any; onOpenSettings: () => void;
|
||||
}) {
|
||||
const [radios, setRadios] = useState<any[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Re-read on every open rather than on a timer: the list changes when the
|
||||
// operator edits it in Settings, which is exactly when they are not looking
|
||||
// at this chip.
|
||||
const load = () => { ListRadios().then((r: any) => setRadios(r ?? [])).catch(() => {}); };
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
|
||||
// What the operator called this radio comes first.
|
||||
//
|
||||
// The chip showed what the RADIO calls itself over CAT — "Kenwood (911)" for
|
||||
// a K3, a bare "CAT" for a Flex that reports nothing useful — which is the
|
||||
// answer to a question nobody asked once the rig has a name in the list. The
|
||||
// model identity is still there, in the tooltip.
|
||||
const active = radios.find((r: any) => r.active);
|
||||
const named = (active?.name || '').trim();
|
||||
const label = catUp
|
||||
? (named || catState.rig || 'CAT')
|
||||
: (catState.enabled ? (named || shortCatError(catState.error) || 'CAT off') : (named || 'CAT'));
|
||||
// The menu opens with ONE radio too.
|
||||
//
|
||||
// It was only shown from two, on the reasoning that a single radio has
|
||||
// nothing to switch to — but that hides the feature exactly from the operator
|
||||
// who has not made a second radio yet, and the click lands on the settings
|
||||
// dialog they were not asking for. With one radio the menu shows that radio
|
||||
// and the way to add another.
|
||||
const has = radios.length > 0;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (!has) { onOpenSettings(); return; } load(); setOpen((o) => !o); }}
|
||||
title={catUp
|
||||
? `${catState.rig || catState.backend || 'connected'}${has ? ' — click to switch radio, right-click for the CAT settings' : ''}`
|
||||
: (catState.error || 'CAT')}
|
||||
onContextMenu={(e) => { e.preventDefault(); onOpenSettings(); }}
|
||||
className={cn('inline-flex items-center gap-1.5 h-5 px-2 rounded-full border text-[11px] transition-colors',
|
||||
'border-border hover:bg-muted cursor-pointer')}
|
||||
>
|
||||
<span className={cn('size-2 rounded-full', catUp ? 'bg-success' : 'bg-muted-foreground/40')} />
|
||||
<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{label}</span>
|
||||
{has && <ChevronUp className="size-3 opacity-60" />}
|
||||
</button>
|
||||
{open && has && (
|
||||
<div className="absolute bottom-full left-0 mb-1 z-50 min-w-44 rounded-md border border-border bg-card shadow-lg py-1">
|
||||
{radios.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
if (r.active) return;
|
||||
SetActiveRadio(r.id).then(load).catch(() => {});
|
||||
}}
|
||||
className={cn('flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs hover:bg-muted',
|
||||
r.active && 'font-semibold text-primary')}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', r.active ? 'bg-success' : 'bg-muted-foreground/30')} />
|
||||
<span className="flex-1 truncate">{(r.name || '').trim() || r.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase">{r.backend}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
<button type="button" onClick={() => { setOpen(false); onOpenSettings(); }}
|
||||
className="w-full px-2.5 py-1 text-left text-xs text-muted-foreground hover:bg-muted">
|
||||
{radios.length > 1 ? 'Radios…' : 'Add a radio…'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shortCatError(err?: string): string {
|
||||
if (!err) return '';
|
||||
const e = err.toLowerCase();
|
||||
@@ -8312,11 +8401,16 @@ export default function App() {
|
||||
</span>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<Chip on={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
|
||||
<Chip
|
||||
on={catUp}
|
||||
label={<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}</span>}
|
||||
title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')}
|
||||
onClick={() => { setSettingsSection('cat'); setShowSettings(true); }}
|
||||
{/* The CAT chip is also the radio switch.
|
||||
With one radio it behaves as it always did — click opens the CAT
|
||||
settings. With several it opens a menu: pick one and OpsLog
|
||||
reconnects to it, leaving the rest of the station alone. The
|
||||
answer to "I have two rigs" used to be "make two profiles",
|
||||
which moves the logbook with it. */}
|
||||
<RadioChip
|
||||
catUp={catUp}
|
||||
catState={catState}
|
||||
onOpenSettings={() => { setSettingsSection('cat'); setShowSettings(true); }}
|
||||
/>
|
||||
<Chip
|
||||
on={rotatorHeading.enabled && rotatorHeading.ok}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
GetListsSettings, SaveListsSettings,
|
||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, DiscoverFlexRadios,
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||||
@@ -1596,6 +1596,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
// exotic or experimental bands not listed).
|
||||
const [bandDraft, setBandDraft] = useState('');
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
// The saved radios. The CAT panel edits ONE of them — whichever is on the air
|
||||
// — and the list is what makes switching possible without touching profiles.
|
||||
const [radios, setRadios] = useState<any[]>([]);
|
||||
const [activeRadio, setActiveRadioId] = useState('');
|
||||
const [radioBusy, setRadioBusy] = useState(false);
|
||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
|
||||
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
||||
@@ -2192,6 +2197,12 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
// on a normal open and Save then wrote an empty list over the operator's
|
||||
// buttons. See rotorPresetsLoaded for the belt to this brace.
|
||||
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
||||
try {
|
||||
const rl: any = await GetRadios();
|
||||
setRadios(rl ?? []);
|
||||
const act = (rl ?? []).find((r: any) => r.active) ?? (rl ?? [])[0];
|
||||
setActiveRadioId(act?.id ?? '');
|
||||
} catch { /* one radio, never listed — the panel works as it always did */ }
|
||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||
@@ -3189,6 +3200,75 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
hint={t('cat.hint')}
|
||||
/>
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
{/* RADIOS. Everything below edits the one selected here, and the status
|
||||
bar's CAT chip switches between them without moving the rest of the
|
||||
station — which is what making a profile per radio used to do. */}
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="shrink-0">{t('cat.radio')}</Label>
|
||||
<Select value={activeRadio || undefined} onValueChange={(v) => {
|
||||
if (v === activeRadio || radioBusy) return;
|
||||
// Saving first: the panel may hold edits to the radio being left
|
||||
// behind, and switching without them would throw them away.
|
||||
setRadioBusy(true);
|
||||
SaveCATSettings(catCfg as any)
|
||||
.then(() => SetActiveRadio(v))
|
||||
.then(async () => {
|
||||
setActiveRadioId(v);
|
||||
setCatCfg(await GetCATSettings() as any);
|
||||
setRadios(((await GetRadios()) ?? []) as any[]);
|
||||
})
|
||||
.catch((e: any) => setErr(String(e?.message ?? e)))
|
||||
.finally(() => setRadioBusy(false));
|
||||
}}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder={t('cat.radio')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{radios.map((r: any, i: number) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name?.trim() || r.label || `Radio ${i + 1}`}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input className="h-9 w-44" placeholder={t('cat.radioNamePh')}
|
||||
value={(radios.find((r: any) => r.id === activeRadio)?.name) ?? ''}
|
||||
onChange={(e) => setRadios((l) => l.map((r: any) => r.id === activeRadio ? { ...r, name: e.target.value } : r))}
|
||||
onBlur={() => { SaveRadios(radios as any).catch(() => {}); }} />
|
||||
<Button type="button" variant="outline" size="sm" className="h-9 shrink-0" disabled={radioBusy}
|
||||
title={t('cat.radioAddHint')}
|
||||
onClick={() => {
|
||||
// A new radio starts from the CURRENT one rather than from
|
||||
// nothing: a second rig is usually the same shape as the first
|
||||
// with one port changed, and an empty form is a form to fill in
|
||||
// twice.
|
||||
const next = [...radios, { id: '', name: '', backend: catCfg.backend, settings: catCfg }];
|
||||
setRadioBusy(true);
|
||||
SaveRadios(next as any)
|
||||
.then(() => GetRadios())
|
||||
.then((rl: any) => setRadios(rl ?? []))
|
||||
.catch((e: any) => setErr(String(e?.message ?? e)))
|
||||
.finally(() => setRadioBusy(false));
|
||||
}}>+</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-9 shrink-0 text-danger"
|
||||
disabled={radioBusy || radios.length < 2}
|
||||
title={t('cat.radioRemoveHint')}
|
||||
onClick={() => {
|
||||
const next = radios.filter((r: any) => r.id !== activeRadio);
|
||||
setRadioBusy(true);
|
||||
SaveRadios(next as any)
|
||||
.then(() => SetActiveRadio(next[0].id))
|
||||
.then(async () => {
|
||||
setActiveRadioId(next[0].id);
|
||||
setCatCfg(await GetCATSettings() as any);
|
||||
setRadios(((await GetRadios()) ?? []) as any[]);
|
||||
})
|
||||
.catch((e: any) => setErr(String(e?.message ?? e)))
|
||||
.finally(() => setRadioBusy(false));
|
||||
}}>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('cat.radioHint')}</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||
{t('cat.enable')}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.26.18';
|
||||
export const APP_VERSION = '0.26.19';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+10
@@ -43,6 +43,8 @@ export function ADIFVersion():Promise<string>;
|
||||
|
||||
export function ActivateProfile(arg1:number):Promise<void>;
|
||||
|
||||
export function ActiveRadioID():Promise<string>;
|
||||
|
||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||
|
||||
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
||||
@@ -541,6 +543,8 @@ export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||
|
||||
export function GetQSORate():Promise<main.QSORate>;
|
||||
|
||||
export function GetRadios():Promise<Array<main.RadioConfig>>;
|
||||
|
||||
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||
|
||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||
@@ -745,6 +749,8 @@ export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function ListRadios():Promise<Array<main.RadioListEntry>>;
|
||||
|
||||
export function ListSerialPorts():Promise<Array<string>>;
|
||||
|
||||
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
||||
@@ -1037,6 +1043,8 @@ export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||
|
||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||
|
||||
export function SaveRadios(arg1:Array<main.RadioConfig>):Promise<void>;
|
||||
|
||||
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||
|
||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||
@@ -1083,6 +1091,8 @@ export function SendLogToDeveloper():Promise<void>;
|
||||
|
||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||
|
||||
export function SetActiveRadio(arg1:string):Promise<void>;
|
||||
|
||||
export function SetActiveRotor(arg1:number):Promise<void>;
|
||||
|
||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||
|
||||
@@ -26,6 +26,10 @@ export function ActivateProfile(arg1) {
|
||||
return window['go']['main']['App']['ActivateProfile'](arg1);
|
||||
}
|
||||
|
||||
export function ActiveRadioID() {
|
||||
return window['go']['main']['App']['ActiveRadioID']();
|
||||
}
|
||||
|
||||
export function AddQSO(arg1) {
|
||||
return window['go']['main']['App']['AddQSO'](arg1);
|
||||
}
|
||||
@@ -1022,6 +1026,10 @@ export function GetQSORate() {
|
||||
return window['go']['main']['App']['GetQSORate']();
|
||||
}
|
||||
|
||||
export function GetRadios() {
|
||||
return window['go']['main']['App']['GetRadios']();
|
||||
}
|
||||
|
||||
export function GetRelayAuto() {
|
||||
return window['go']['main']['App']['GetRelayAuto']();
|
||||
}
|
||||
@@ -1430,6 +1438,10 @@ export function ListQSOFiltered(arg1) {
|
||||
return window['go']['main']['App']['ListQSOFiltered'](arg1);
|
||||
}
|
||||
|
||||
export function ListRadios() {
|
||||
return window['go']['main']['App']['ListRadios']();
|
||||
}
|
||||
|
||||
export function ListSerialPorts() {
|
||||
return window['go']['main']['App']['ListSerialPorts']();
|
||||
}
|
||||
@@ -2014,6 +2026,10 @@ export function SaveQSLDefaults(arg1) {
|
||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRadios(arg1) {
|
||||
return window['go']['main']['App']['SaveRadios'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRelayAuto(arg1) {
|
||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||
}
|
||||
@@ -2106,6 +2122,10 @@ export function SendQSORecordingEmail(arg1) {
|
||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||
}
|
||||
|
||||
export function SetActiveRadio(arg1) {
|
||||
return window['go']['main']['App']['SetActiveRadio'](arg1);
|
||||
}
|
||||
|
||||
export function SetActiveRotor(arg1) {
|
||||
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
||||
}
|
||||
|
||||
@@ -3569,6 +3569,60 @@ export namespace main {
|
||||
}
|
||||
}
|
||||
|
||||
export class RadioConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
settings: CATSettings;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RadioConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.settings = this.convertValues(source["settings"], CATSettings);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class RadioListEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
backend: string;
|
||||
active: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RadioListEntry(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.label = source["label"];
|
||||
this.backend = source["backend"];
|
||||
this.active = source["active"];
|
||||
}
|
||||
}
|
||||
export class RelayAutoRule {
|
||||
device_id: string;
|
||||
relay: number;
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.26.18"
|
||||
appVersion = "0.26.19"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user