Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32dbfbd04e | ||
|
|
a930a4f02d | ||
|
|
f98a831195 | ||
|
|
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
|
A modern, fast ham-radio logger for Windows — single-strip entry, real-time CAT
|
||||||
synchronization between transceiver/receiver, contest loggers, digital mode software, skimmers
|
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||||
and other software, as well as external power amplifiers, bandpass filter units, antenna switches,
|
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||||
radio controllers and other devices.
|
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
|
Developed by **F4BPO**.
|
||||||
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.).
|
|
||||||
|
|
||||||
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
|
## Building / developing
|
||||||
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.
|
|
||||||
|
|
||||||
Our company advocates universal unification of data exchange between devices and
|
- **Dev:** `wails dev` (Vite hot-reload; Go methods reachable at http://localhost:34115).
|
||||||
software by creating the TCI interface for this purpose. Modern transceivers and software must
|
- **Build:** `wails build` (use the project's wails v2.11 — `~/go/bin/wails.exe`).
|
||||||
communicate using one protocol - the TCI protocol.
|
- **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
|
## Logging
|
||||||
arguments corresponding to this command. There are reserved characters that cannot be
|
|
||||||
included in the command name and command arguments.
|
|
||||||
|
|
||||||
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:
|
## Maps & antenna
|
||||||
1. Name of the command;
|
|
||||||
2. Separating character between command name and arguments «:»;
|
|
||||||
3. Separating character between arguments «,»;
|
|
||||||
4. End of the command character «;».
|
|
||||||
|
|
||||||
If a command has no arguments, an end of command symbol is placed after the command
|
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||||
name. If the command is invalid, it is ignored. The case of letters does not matter.
|
*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
|
## DX Cluster
|
||||||
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.
|
|
||||||
|
|
||||||
When a parameter change occurs in the ExpertSDR3 (server) program, the server notifies
|
- Multiple cluster servers with auto-reconnect, a master for commands.
|
||||||
all connected clients, i.e., clients do not need to poll the server constantly, any change of state
|
- **Filter sidebar** (callsign search, hide-worked, group duplicates, band /
|
||||||
will be sent in time to all clients. If the client sends a new state, the server will set it to itself, as
|
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
||||||
well as send it to all clients, that is, the server acts as a synchronizer. All clients connected to the
|
pane, with a show/hide toggle.
|
||||||
server will be automatically synchronized. This way of work allows to minimize network load,
|
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
||||||
reducing traffic.
|
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
|
## CAT control
|
||||||
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.
|
|
||||||
|
|
||||||
TCI is also used to transmit audio signals of the receiver to clients and to receive audio
|
Six native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||||
signals from clients, i.e., the client can transmit audio signals to ExpertSDR3 for radio
|
non-blocking connect so a powered-off radio never freezes the app. The settings
|
||||||
transmission. The audio stream exchange is designed to work with digital modes, where encoding
|
ask two questions rather than one — **which radio**, then **how it is
|
||||||
and decoding is performed by third-party software, as well as in voice modes, where audio macros
|
connected** — and only offer the links that radio actually has:
|
||||||
can be broadcasted, which is very much in demand in contest loggers.
|
|
||||||
|
|
||||||
When working in contests, it is important to record all on the air operation, for this purpose
|
- **OmniRig** (Rig 1/2, hot-swap) — works with any OmniRig-supported rig.
|
||||||
the audio stream from the line-output is sent to all clients. The resulting audio stream can be
|
- **FlexRadio (SmartSDR)** over the radio's TCP API — real-time slice freq /
|
||||||
recorded to a file or played back with a PC sound card.
|
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).*
|
||||||
|
|||||||
@@ -3338,6 +3338,10 @@ func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
|
|||||||
// Per-band rig/antenna from Operating conditions (the antenna ticked as
|
// Per-band rig/antenna from Operating conditions (the antenna ticked as
|
||||||
// DEFAULT for this band) — the same auto-fill the entry strip does, applied
|
// DEFAULT for this band) — the same auto-fill the entry strip does, applied
|
||||||
// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
|
// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
|
||||||
|
// The radio that is CONNECTED names itself first — see activeRadioMyRig.
|
||||||
|
if q.MyRig == "" {
|
||||||
|
q.MyRig = a.activeRadioMyRig()
|
||||||
|
}
|
||||||
if a.operating != nil && q.Band != "" && (q.MyRig == "" || q.MyAntenna == "") {
|
if a.operating != nil && q.Band != "" && (q.MyRig == "" || q.MyAntenna == "") {
|
||||||
if d, ok, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok {
|
if d, ok, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok {
|
||||||
if q.MyRig == "" {
|
if q.MyRig == "" {
|
||||||
@@ -8324,6 +8328,12 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
return err
|
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)
|
a.restartAsync("cat", a.reloadCAT)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -13395,7 +13405,12 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
|
|
||||||
// ── Operating-conditions stamp ──
|
// ── Operating-conditions stamp ──
|
||||||
// Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for
|
// Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for
|
||||||
// this band (if the user has configured Operating conditions).
|
// this band (if the user has configured Operating conditions) — after the
|
||||||
|
// connected radio has had its say, since it knows which rig is keying and
|
||||||
|
// the band default only knows which one was planned.
|
||||||
|
if q.MyRig == "" {
|
||||||
|
q.MyRig = a.activeRadioMyRig()
|
||||||
|
}
|
||||||
if a.operating != nil && a.profiles != nil {
|
if a.operating != nil && a.profiles != nil {
|
||||||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
if d, ok2, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok2 {
|
if d, ok2, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok2 {
|
||||||
|
|||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
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"`
|
||||||
|
// MyRig is what goes into MY_RIG on a QSO made with this radio.
|
||||||
|
//
|
||||||
|
// It belongs here rather than in Operating conditions once there is more
|
||||||
|
// than one rig: the operating conditions describe a PLAN — "on 20 m I use
|
||||||
|
// the beam and the 7300" — while this is the fact of which radio is keying.
|
||||||
|
// Left empty, nothing changes: the old chain (band default, then the
|
||||||
|
// profile's rig) answers exactly as it did.
|
||||||
|
MyRig string `json:"my_rig"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeRadioMyRig is the MY_RIG of the radio currently connected, or "".
|
||||||
|
//
|
||||||
|
// Consulted BEFORE the per-band default, and deliberately: the band default is
|
||||||
|
// what the operator planned to use on that band, this is which radio is
|
||||||
|
// actually on the air. When they disagree — a second rig borrowed for one
|
||||||
|
// evening on 20 m — the one that is transmitting is the true answer.
|
||||||
|
func (a *App) activeRadioMyRig() string {
|
||||||
|
if a.settings == nil || strings.TrimSpace(a.settingOr(keyRadiosList, "")) == "" {
|
||||||
|
return "" // no list was ever made: nothing to say, nothing changes
|
||||||
|
}
|
||||||
|
list, err := a.GetRadios()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id := a.ActiveRadioID()
|
||||||
|
for _, r := range list {
|
||||||
|
if r.ID == id {
|
||||||
|
return strings.TrimSpace(r.MyRig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -1,4 +1,24 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.26.20",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Each radio carries its own MY_RIG (Settings → CAT), written on every QSO made with it — ahead of the per-band station in Operating conditions, which says what you planned to use rather than which radio is keying. Left empty, nothing changes."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.26.18",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+100
-6
@@ -1,7 +1,7 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
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';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
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 { 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 { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
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
|
// shortCatError condenses a backend error into a few words for the topbar
|
||||||
// pill. The full message stays in the tooltip. Recognises the common cases
|
// pill. The full message stays in the tooltip. Recognises the common cases
|
||||||
// (OmniRig not installed, not registered) and otherwise truncates.
|
// (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 {
|
function shortCatError(err?: string): string {
|
||||||
if (!err) return '';
|
if (!err) return '';
|
||||||
const e = err.toLowerCase();
|
const e = err.toLowerCase();
|
||||||
@@ -8312,11 +8401,16 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
<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={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
|
||||||
<Chip
|
{/* The CAT chip is also the radio switch.
|
||||||
on={catUp}
|
With one radio it behaves as it always did — click opens the CAT
|
||||||
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>}
|
settings. With several it opens a menu: pick one and OpsLog
|
||||||
title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')}
|
reconnects to it, leaving the rest of the station alone. The
|
||||||
onClick={() => { setSettingsSection('cat'); setShowSettings(true); }}
|
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
|
<Chip
|
||||||
on={rotatorHeading.enabled && rotatorHeading.ok}
|
on={rotatorHeading.enabled && rotatorHeading.ok}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
GetListsSettings, SaveListsSettings,
|
GetListsSettings, SaveListsSettings,
|
||||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, DiscoverFlexRadios,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||||
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||||||
@@ -1596,6 +1596,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
// exotic or experimental bands not listed).
|
// exotic or experimental bands not listed).
|
||||||
const [bandDraft, setBandDraft] = useState('');
|
const [bandDraft, setBandDraft] = useState('');
|
||||||
const [modeDraft, setModeDraft] = 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>({
|
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,
|
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: '',
|
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
|
// on a normal open and Save then wrote an empty list over the operator's
|
||||||
// buttons. See rotorPresetsLoaded for the belt to this brace.
|
// buttons. See rotorPresetsLoaded for the belt to this brace.
|
||||||
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
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 { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
@@ -3189,6 +3200,87 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
hint={t('cat.hint')}
|
hint={t('cat.hint')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-3xl">
|
<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>
|
||||||
|
{/* MY_RIG follows the radio, not the profile.
|
||||||
|
Operating conditions describe a PLAN — "on 20 m I use the beam
|
||||||
|
and the 7300" — while this is the fact of which rig is keying.
|
||||||
|
Empty leaves the old chain alone. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="shrink-0 w-24">{t('cat.radioMyRig')}</Label>
|
||||||
|
<Input className="h-9 flex-1" placeholder={t('cat.radioMyRigPh')}
|
||||||
|
value={(radios.find((r: any) => r.id === activeRadio)?.my_rig) ?? ''}
|
||||||
|
onChange={(e) => setRadios((l) => l.map((r: any) => r.id === activeRadio ? { ...r, my_rig: e.target.value } : r))}
|
||||||
|
onBlur={() => { SaveRadios(radios as any).catch(() => {}); }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('cat.radioHint')}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('cat.radioMyRigHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||||
{t('cat.enable')}
|
{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).
|
// 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).
|
// 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.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
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 ActivateProfile(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function ActiveRadioID():Promise<string>;
|
||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
export function AmpFanMode(arg1:string,arg2:string):Promise<void>;
|
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 GetQSORate():Promise<main.QSORate>;
|
||||||
|
|
||||||
|
export function GetRadios():Promise<Array<main.RadioConfig>>;
|
||||||
|
|
||||||
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
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 ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function ListRadios():Promise<Array<main.RadioListEntry>>;
|
||||||
|
|
||||||
export function ListSerialPorts():Promise<Array<string>>;
|
export function ListSerialPorts():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
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 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 SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):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 SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetActiveRadio(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetActiveRotor(arg1:number):Promise<void>;
|
export function SetActiveRotor(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export function ActivateProfile(arg1) {
|
|||||||
return window['go']['main']['App']['ActivateProfile'](arg1);
|
return window['go']['main']['App']['ActivateProfile'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ActiveRadioID() {
|
||||||
|
return window['go']['main']['App']['ActiveRadioID']();
|
||||||
|
}
|
||||||
|
|
||||||
export function AddQSO(arg1) {
|
export function AddQSO(arg1) {
|
||||||
return window['go']['main']['App']['AddQSO'](arg1);
|
return window['go']['main']['App']['AddQSO'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1026,10 @@ export function GetQSORate() {
|
|||||||
return window['go']['main']['App']['GetQSORate']();
|
return window['go']['main']['App']['GetQSORate']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetRadios() {
|
||||||
|
return window['go']['main']['App']['GetRadios']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRelayAuto() {
|
export function GetRelayAuto() {
|
||||||
return window['go']['main']['App']['GetRelayAuto']();
|
return window['go']['main']['App']['GetRelayAuto']();
|
||||||
}
|
}
|
||||||
@@ -1430,6 +1438,10 @@ export function ListQSOFiltered(arg1) {
|
|||||||
return window['go']['main']['App']['ListQSOFiltered'](arg1);
|
return window['go']['main']['App']['ListQSOFiltered'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListRadios() {
|
||||||
|
return window['go']['main']['App']['ListRadios']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListSerialPorts() {
|
export function ListSerialPorts() {
|
||||||
return window['go']['main']['App']['ListSerialPorts']();
|
return window['go']['main']['App']['ListSerialPorts']();
|
||||||
}
|
}
|
||||||
@@ -2014,6 +2026,10 @@ export function SaveQSLDefaults(arg1) {
|
|||||||
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
return window['go']['main']['App']['SaveQSLDefaults'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRadios(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRadios'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRelayAuto(arg1) {
|
export function SaveRelayAuto(arg1) {
|
||||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2106,6 +2122,10 @@ export function SendQSORecordingEmail(arg1) {
|
|||||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetActiveRadio(arg1) {
|
||||||
|
return window['go']['main']['App']['SetActiveRadio'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetActiveRotor(arg1) {
|
export function SetActiveRotor(arg1) {
|
||||||
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3569,6 +3569,62 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RadioConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
settings: CATSettings;
|
||||||
|
my_rig: string;
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.my_rig = source["my_rig"];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
export class RelayAutoRule {
|
||||||
device_id: string;
|
device_id: string;
|
||||||
relay: number;
|
relay: number;
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// 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
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user