Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9f8380ece | ||
|
|
10e1504531 | ||
|
|
2e3464104d | ||
|
|
dc8f9c5099 | ||
|
|
08905a1680 | ||
|
|
fd939797dd | ||
|
|
a621a3ee62 | ||
|
|
ae4a56dac1 | ||
|
|
fdc2378191 |
@@ -1,322 +1,77 @@
|
||||
# OpsLog
|
||||
# Transceiver Control Interface
|
||||
|
||||
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||
</a>
|
||||
## Introduction
|
||||
|
||||
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 (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.
|
||||
|
||||
Developed by **F4BPO**.
|
||||
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.).
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
## Building / developing
|
||||
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.
|
||||
|
||||
- **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.
|
||||
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.
|
||||
|
||||
---
|
||||
## Interface description
|
||||
|
||||
## Logging
|
||||
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.
|
||||
|
||||
- **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.
|
||||
- **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).
|
||||
List of reserved characters: «:», «,», «;».
|
||||
|
||||
## Maps & antenna
|
||||
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 «;».
|
||||
|
||||
- **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 **FlexRadio controls**, the **Icom
|
||||
console** or the **Net control** panel.
|
||||
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||
basemaps (Light / Voyager / Street / Satellite, all 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.
|
||||
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.
|
||||
|
||||
## DX Cluster
|
||||
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.
|
||||
|
||||
- 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*).
|
||||
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.
|
||||
|
||||
## CAT control
|
||||
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.
|
||||
|
||||
Four native backends (Settings → CAT), each with auto-reconnect and a fast,
|
||||
non-blocking connect so a powered-off radio never freezes the app:
|
||||
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.
|
||||
|
||||
- **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.
|
||||
- **TCI** (WebSocket) — SunSDR / ExpertSDR2 and any TCI-compatible server:
|
||||
freq / mode / PTT / split, plus optional panorama spots.
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
## 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.
|
||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||
antenna-switch widget.
|
||||
- **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).
|
||||
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — 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.
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 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).*
|
||||
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.
|
||||
|
||||
@@ -8498,6 +8498,11 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
// anything else closes it. Done HERE rather than left to the next restart:
|
||||
// a device chosen in a dropdown that only takes effect after a relaunch
|
||||
// reads as a device that does not work.
|
||||
// The transmit source follows the "To radio" device, and is applied now:
|
||||
// the radio is told which input to use at the moment it is keyed, so the
|
||||
// setting has to be right before the next message rather than after the
|
||||
// next restart.
|
||||
a.applyTCITXSource()
|
||||
if s.FromRadio == audio.NetworkDeviceID {
|
||||
a.startTCIRecording()
|
||||
} else if a.tciAudioAvailable() {
|
||||
@@ -20070,7 +20075,27 @@ func (a *App) IsNewUSCounty(state, cnty string) bool {
|
||||
// native ones: an operator on OmniRig or Flex gets the same server.
|
||||
type catShareRig struct{ a *App }
|
||||
|
||||
func (r catShareRig) Freq() int64 { return r.a.cat.State().FreqHz }
|
||||
// Freq is what a rigctl client gets for "f": the frequency of the VFO in use,
|
||||
// which is where we LISTEN.
|
||||
//
|
||||
// This returned FreqHz, and RigState follows ADIF where FreqHz is the TRANSMIT
|
||||
// frequency — so with split on, every client asking "what frequency is the
|
||||
// radio on" was told VFO B. Reported from a station running an IC-7850: turning
|
||||
// VFO B moved VFO A. Nothing in OpsLog was writing to the radio; a client was
|
||||
// reading the dial, being handed the wrong VFO, and writing it back.
|
||||
//
|
||||
// The split TX frequency is a separate question, and Hamlib has a separate
|
||||
// command for it ("i" / get_split_freq) which Split() below answers.
|
||||
func (r catShareRig) Freq() int64 { return shareRXFreq(r.a.cat.State()) }
|
||||
|
||||
// shareRXFreq is that rule on its own, so it can be pinned by a test: it is one
|
||||
// line, it was wrong, and being wrong cost a station its VFO A.
|
||||
func shareRXFreq(st cat.RigState) int64 {
|
||||
if st.Split && st.RxFreqHz > 0 {
|
||||
return st.RxFreqHz
|
||||
}
|
||||
return st.FreqHz
|
||||
}
|
||||
func (r catShareRig) Mode() string { return r.a.cat.State().Mode }
|
||||
|
||||
// Split reports the flag and the OTHER VFO's frequency. RigState follows ADIF —
|
||||
|
||||
@@ -45,6 +45,31 @@ func (a *App) tciTXPlayer(pcm []byte, rate, ch, bits int, stop <-chan struct{})
|
||||
return player.PlayTXAudio(pcm, rate, ch, bits, stop)
|
||||
}
|
||||
|
||||
// applyTCITXSource tells the radio where to take its transmit audio from,
|
||||
// following the "To radio" device.
|
||||
//
|
||||
// This is the setting an operator would otherwise have to find in ExpertSDR3
|
||||
// and set again for every mode, because it is remembered per mode there. It is
|
||||
// the third argument of TRX, so OpsLog can simply say it each time it keys —
|
||||
// and a radio told nothing keeps the operator's microphone, which is what
|
||||
// every PTT that is not a voice message should do.
|
||||
func (a *App) applyTCITXSource() {
|
||||
if a.cat == nil {
|
||||
return
|
||||
}
|
||||
cfg, _ := a.GetAudioSettings()
|
||||
src := ""
|
||||
if cfg.ToRadio == audio.NetworkDeviceID {
|
||||
src = "tci"
|
||||
}
|
||||
_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error {
|
||||
if s, ok := t.(interface{ SetTXAudioSource(string) }); ok {
|
||||
s.SetTXAudioSource(src)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// installTCITXPlayer offers the radio as an audio output, or withdraws it.
|
||||
//
|
||||
// Withdrawing matters as much as offering: a radio that has gone away must stop
|
||||
@@ -56,5 +81,6 @@ func (a *App) installTCITXPlayer(on bool) {
|
||||
return
|
||||
}
|
||||
audio.SetNetworkPlayer(a.tciTXPlayer)
|
||||
a.applyTCITXSource()
|
||||
applog.Printf("tci: the radio is available as an audio output — no virtual cable needed for the voice keyer")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
// What a rigctl client is told when it asks "what frequency is the radio on".
|
||||
//
|
||||
// Hamlib's "f" means the VFO IN USE — where the operator is listening. RigState
|
||||
// follows ADIF, where FreqHz is where we TRANSMIT, and the two are the same
|
||||
// number until split is engaged. They were handed over unchanged, so with split
|
||||
// on every client asking for the dial was given VFO B.
|
||||
//
|
||||
// Reported from a station running an IC-7850 over USB: turning VFO B moved
|
||||
// VFO A. OpsLog was writing nothing to that radio — a client read the dial, was
|
||||
// handed the transmit VFO, and wrote it back as the dial.
|
||||
func TestShareRXFreqAnswersTheListeningVFO(t *testing.T) {
|
||||
const rx, tx = 14195000, 14200500
|
||||
cases := []struct {
|
||||
name string
|
||||
st cat.RigState
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
name: "simplex — the one frequency there is",
|
||||
st: cat.RigState{FreqHz: rx},
|
||||
want: rx,
|
||||
},
|
||||
{
|
||||
name: "split — the RX VFO, not the TX one",
|
||||
st: cat.RigState{Split: true, FreqHz: tx, RxFreqHz: rx},
|
||||
want: rx,
|
||||
},
|
||||
{
|
||||
// A backend that reports split without ever filling RxFreqHz would
|
||||
// otherwise be answered with 0, and a client told the radio is on
|
||||
// 0 Hz does something worse than nothing with it.
|
||||
name: "split claimed but no RX frequency known",
|
||||
st: cat.RigState{Split: true, FreqHz: tx},
|
||||
want: tx,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := shareRXFreq(c.st); got != c.want {
|
||||
t.Errorf("shareRXFreq(%+v) = %d, want %d", c.st, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// And the pair, stated together: the two questions must not return the same
|
||||
// answer while split is on, or the distinction has been lost somewhere.
|
||||
func TestShareSplitReportsTheOtherVFO(t *testing.T) {
|
||||
st := cat.RigState{Split: true, FreqHz: 14200500, RxFreqHz: 14195000}
|
||||
rxAnswer := shareRXFreq(st)
|
||||
txAnswer := st.FreqHz // what Split() hands back for "i" / get_split_freq
|
||||
if rxAnswer == txAnswer {
|
||||
t.Fatalf("get_freq and get_split_freq both answered %d — a client cannot tell the VFOs apart", rxAnswer)
|
||||
}
|
||||
if rxAnswer != st.RxFreqHz || txAnswer != st.FreqHz {
|
||||
t.Errorf("got rx=%d tx=%d, want rx=%d tx=%d", rxAnswer, txAnswer, st.RxFreqHz, st.FreqHz)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,28 @@
|
||||
[
|
||||
{
|
||||
"version": "0.26.17",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Awards, RDA district comparison: each disagreement can now be settled on its own row. Click the district you keep — the log's or the database's — and apply. It is written into the contact's CNTY and its award reference, and the settled rows leave the list.",
|
||||
"The band/mode matrix: a confirmed slot now shows as confirmed. A callsign worked and not confirmed was outranking an entity CONFIRMED on the same band and mode, so a slot that needs nothing was painted as if it still did.",
|
||||
"CAT sharing: a program connected to OpsLog's rigctl server is told the frequency it is LISTENING on. With split engaged it was given the transmit VFO instead, so a client that reads the dial and writes it back — WSJT-X and its like — could move VFO A when VFO B was turned."
|
||||
],
|
||||
"fr": [
|
||||
"Diplômes, comparaison des districts RDA : chaque divergence se règle désormais sur sa propre ligne. Cliquer le district qu'on garde — celui du log ou celui de la base — puis appliquer. Il est écrit dans le CNTY du contact et dans sa référence de diplôme, et les lignes réglées quittent la liste.",
|
||||
"Matrice bandes/modes : une case confirmée s'affiche enfin comme confirmée. Un indicatif travaillé et non confirmé l'emportait sur une entité CONFIRMÉE sur la même bande et le même mode, si bien qu'une case qui ne demandait plus rien était peinte comme s'il manquait encore quelque chose.",
|
||||
"Partage CAT : un programme connecté au serveur rigctl d'OpsLog reçoit la fréquence sur laquelle on ÉCOUTE. En split, c'était le VFO d'émission qui lui était donné, si bien qu'un client qui lit le VFO et le réécrit — WSJT-X et consorts — pouvait déplacer le VFO A quand on tournait le VFO B."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.16",
|
||||
"date": "",
|
||||
"en": [
|
||||
"TCI transmit needs no setting up in ExpertSDR3 any more. The protocol lets the audio source be named when keying, so OpsLog does it — the voice keyer works in SSB as well as in the digital modes, nothing has to be set per mode in the radio's own window, and the microphone stays the source for every transmission that is not a voice message."
|
||||
],
|
||||
"fr": [
|
||||
"L'émission TCI ne demande plus aucun réglage dans ExpertSDR3. Le protocole permet de nommer la source audio au moment de passer en émission, et OpsLog le fait — le manipulateur vocal fonctionne en SSB comme en numérique, il n'y a plus rien à régler mode par mode dans la fenêtre de la radio, et le micro reste la source pour toute émission qui n'est pas un message vocal."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.26.15",
|
||||
"date": "",
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources,
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources, ApplyRDAChoices,
|
||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||
GetPSUSettings, SavePSUSettings,
|
||||
@@ -1946,6 +1946,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
// nobody had pressed, and the one that had been pressed showed nothing at
|
||||
// all, which reads as "the button does nothing".
|
||||
const [rdaCmpMsg, setRdaCmpMsg] = useState<string>('');
|
||||
// Which source the operator kept, per QSO id. By id and not by row: a second
|
||||
// comparison rebuilds the list, and a choice that followed a row number would
|
||||
// then be applied to somebody else's contact.
|
||||
const [rdaPick, setRdaPick] = useState<Record<number, 'log' | 'db'>>({});
|
||||
const [rdaApplyBusy, setRdaApplyBusy] = useState(false);
|
||||
const runRDACompare = async () => {
|
||||
setRdaCmpBusy(true); setRdaCmp(null); setRdaCmpMsg('');
|
||||
try {
|
||||
@@ -7517,6 +7522,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<th className="py-1 pr-2 font-medium">{t('rda.cmpFromLog')}</th>
|
||||
<th className="py-1 pr-2 font-medium">{t('rda.cmpFromDb')}</th>
|
||||
<th className="py-1 pr-2 font-medium">{t('rda.cmpKind')}</th>
|
||||
<th className="py-1 pr-2 font-medium text-center">{t('rda.cmpKeep')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -7541,11 +7547,100 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<td className="py-1 pr-2 text-muted-foreground">
|
||||
{c.dated ? t('rda.cmpDated') : t('rda.cmpCurrent')}
|
||||
</td>
|
||||
{/* Which one wins, on the row that shows the two values.
|
||||
Two buttons rather than a tick box: a tick box would
|
||||
have to mean one of the sources silently, and an
|
||||
operator arbitrating between "RO-19" and "KO-05"
|
||||
should be picking the value they can read, not
|
||||
remembering what a checked box stands for. */}
|
||||
<td className="py-1 pr-2 whitespace-nowrap text-center">
|
||||
<div className="inline-flex rounded border border-border overflow-hidden">
|
||||
{(['log', 'db'] as const).map((side) => (
|
||||
<button
|
||||
key={side}
|
||||
type="button"
|
||||
onClick={() => setRdaPick((p) => {
|
||||
const next = { ...p };
|
||||
if (next[c.qso_id] === side) delete next[c.qso_id];
|
||||
else next[c.qso_id] = side;
|
||||
return next;
|
||||
})}
|
||||
className={`px-2 py-0.5 font-mono text-[10px] ${rdaPick[c.qso_id] === side
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'}`}
|
||||
title={side === 'log' ? t('rda.cmpKeepLog') : t('rda.cmpKeepDb')}
|
||||
>
|
||||
{side === 'log' ? c.from_log : c.from_db}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Applying is a separate, deliberate act. Clicking through two
|
||||
hundred rows and having each one written as it is clicked would
|
||||
make a slip permanent before the operator had finished
|
||||
reading. */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button size="sm" variant="secondary" disabled={rdaApplyBusy || Object.keys(rdaPick).length === 0}
|
||||
onClick={async () => {
|
||||
setRdaApplyBusy(true);
|
||||
try {
|
||||
const choices = rdaCmp.conflicts
|
||||
.filter((c: any) => rdaPick[c.qso_id])
|
||||
.map((c: any) => ({
|
||||
qso_id: c.qso_id,
|
||||
district: rdaPick[c.qso_id] === 'log' ? c.from_log : c.from_db,
|
||||
}));
|
||||
const r: any = await ApplyRDAChoices(choices);
|
||||
setRdaCmpMsg(r?.message || '');
|
||||
setRdaPick({});
|
||||
// The settled rows leave the list, and the counters follow.
|
||||
//
|
||||
// They used to stay, which made a working button look like a
|
||||
// broken one: the operator decided a hundred contacts, the
|
||||
// hundred rows stayed exactly as they were, and there was
|
||||
// nothing anywhere to say the writing had happened. Dropping
|
||||
// them here rather than re-running the comparison, because
|
||||
// that reads the whole log — seconds of silence on a remote
|
||||
// database, to be told what is already known.
|
||||
if (r?.applied > 0) {
|
||||
const settled = new Set(choices.map((c: any) => c.qso_id));
|
||||
setRdaCmp((prev: any) => prev && ({
|
||||
...prev,
|
||||
disagree: Math.max(0, (prev.disagree ?? 0) - r.applied),
|
||||
agree: (prev.agree ?? 0) + r.applied,
|
||||
conflicts: (prev.conflicts ?? []).filter((c: any) => !settled.has(c.qso_id)),
|
||||
}));
|
||||
}
|
||||
} catch (e: any) {
|
||||
setRdaCmpMsg(String(e?.message ?? e));
|
||||
} finally {
|
||||
setRdaApplyBusy(false);
|
||||
}
|
||||
}}>
|
||||
{rdaApplyBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
|
||||
{t('rda.cmpApply', { n: Object.keys(rdaPick).length })}
|
||||
</Button>
|
||||
<button type="button" className="text-[11px] underline decoration-dotted hover:text-primary"
|
||||
onClick={() => setRdaPick(Object.fromEntries(rdaCmp.conflicts.map((c: any) => [c.qso_id, 'db'])))}>
|
||||
{t('rda.cmpAllDb')}
|
||||
</button>
|
||||
<button type="button" className="text-[11px] underline decoration-dotted hover:text-primary"
|
||||
onClick={() => setRdaPick(Object.fromEntries(rdaCmp.conflicts.map((c: any) => [c.qso_id, 'log'])))}>
|
||||
{t('rda.cmpAllLog')}
|
||||
</button>
|
||||
{Object.keys(rdaPick).length > 0 && (
|
||||
<button type="button" className="text-[11px] underline decoration-dotted hover:text-primary"
|
||||
onClick={() => setRdaPick({})}>
|
||||
{t('rda.cmpClear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('rda.cmpApplyHint')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -150,7 +150,7 @@ const en: Dict = {
|
||||
'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
|
||||
'sec.ftx': 'FTx decodes', 'ftx.hint': 'What OpsLog does on its own with the digital decode stream.', 'ftx.enable': 'Auto-call', 'ftx.enableHint': 'answer a decode without clicking it', 'ftx.callWhen': 'Call a station that is:', 'ftx.watch': 'Watch list', 'ftx.watchHint': 'One callsign per line, wildcards allowed (4S7*, */P). A watched station is answered ahead of the criteria above.', 'ftx.watchOnlyIf': 'but only if it is also:', 'ftx.cooldown': 'Ignore a callsign for', 'ftx.warn': 'This keys your transmitter without asking. It answers CQ only, never while you are already transmitting, one station at a time, and every call is written to the log file with its reason. Halt stops it.', 'ftx.c_dxcc': 'a new DXCC entity', 'ftx.c_bandmode': 'a new band AND a new mode for the entity', 'ftx.c_band': 'a new band for the entity', 'ftx.c_mode': 'a new mode for the entity', 'ftx.c_slot': 'a new slot (band+mode never worked together)', 'ftx.c_grid': 'a new grid square', 'ftx.c_county': 'a new US county', 'ftx.c_pota': 'a new POTA park', 'ftx.c_pfx': 'a new WPX prefix',
|
||||
'sec.bands': 'Bands', 'sec.satellites': 'Satellites', 'sat.hint': 'The satellites this station works. They are offered as a dropdown on the satellite fields, in alphabetical order.', 'sat.listLabel': 'One satellite per line', 'sat.listHint': 'Written into SAT_NAME exactly as spelled here, so use the name LoTW and the awards expect — AO-91, not AO91. Leaving the list empty simply keeps the field a plain text box.', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
||||
'sec.udp': 'Connections', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties', 'nav.maintenance': 'Maintenance', 'sec.databases': 'Databases', 'db.hint': 'The reference data OpsLog keeps on disk. One line each, with what it holds and when it was last refreshed.', 'db.update': 'Update', 'db.never': 'never downloaded', 'db.cty': 'Country file (cty.dat)', 'db.ctyDetail': '{n} entities · file dated {d}', 'db.clublog': 'Club Log country exceptions', 'db.clublogDetail': '{n} exceptions · {d}', 'db.lotwUsers': 'LoTW users', 'db.lotwDetail': '{n} callsigns · {d}', 'db.scp': 'Super Check Partial (MASTER.SCP)', 'db.scpDetail': '{n} callsigns · {d}', 'db.uls': 'US counties (FCC ULS)', 'db.ulsDetail': '{n} callsigns · {d}', 'db.rda': 'Russian districts (RDA)', 'db.rdaDetail': '{n} callsigns, with their dated activity periods', 'db.rdaNote': 'built in', 'db.refLists': 'Award reference lists', 'db.refListsHint': 'Only the awards with an online source appear here; the others are shipped or edited by hand.', 'db.refDetail': '{n} references · {d}', 'db.refUpdated': '{code}: {n} references.', 'db.noRefLists': 'No award has an online reference list.', 'sec.rda': 'Russian districts (RDA)', 'rda.hint': 'The offline district database, and the one bulk operation it feeds.', 'rda.dbTitle': 'District database', 'rda.dbCount': '{n} Russian callsigns, each with the district it operates from and, where it moved, the dated periods it operated from each one. Built into OpsLog — nothing to download.', 'rda.backfillTitle': 'Fill the district on existing QSOs', 'rda.backfillIntro': 'Goes through every contact with a Russian entity and assigns its RDA reference, using the district the station was in ON THE DAY of the contact.', 'rda.useCurrent': 'Also use the current district for stations with no recorded history', 'rda.useCurrentHint': '(true for the great majority — the database records a history precisely for the callsigns that moved — but it is an assumption, not a dated fact)', 'rda.backfillRun': 'Fill districts', 'rda.backfillDone': '{s} Russian QSOs — {d} from a dated record, {c} from the current district, {u} unknown, {k} already had one.', 'rda.cmpTitle': 'Compare the two district sources', 'rda.cmpRunning': 'Comparing…', 'rda.cmpNoConflict': 'No disagreement — both sources say the same district everywhere.', 'rda.cmpNoRussian': 'No Russian contacts to compare.', 'rda.backfillRunning': 'Filling…', 'rda.cmpRun': 'Compare', 'rda.cmpDone': '{s} Russian QSOs — {a} identical, {d} differ, {l} only in the log, {b} only in the database', 'rda.cmpCall': 'Callsign', 'rda.cmpDate': 'Date', 'rda.cmpFromLog': 'Log (CNTY)', 'rda.cmpFromDb': 'RDA database', 'rda.cmpListHint': 'Showing {n} of {d} — click a callsign to open the contact.', 'rda.cmpOpen': 'Open this QSO', 'rda.cmpKind': 'Database', 'rda.cmpDated': 'dated', 'rda.cmpCurrent': 'current', 'rda.neverOverwrites': 'A reference you assigned by hand is never overwritten.',
|
||||
'sec.udp': 'Connections', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties', 'nav.maintenance': 'Maintenance', 'sec.databases': 'Databases', 'db.hint': 'The reference data OpsLog keeps on disk. One line each, with what it holds and when it was last refreshed.', 'db.update': 'Update', 'db.never': 'never downloaded', 'db.cty': 'Country file (cty.dat)', 'db.ctyDetail': '{n} entities · file dated {d}', 'db.clublog': 'Club Log country exceptions', 'db.clublogDetail': '{n} exceptions · {d}', 'db.lotwUsers': 'LoTW users', 'db.lotwDetail': '{n} callsigns · {d}', 'db.scp': 'Super Check Partial (MASTER.SCP)', 'db.scpDetail': '{n} callsigns · {d}', 'db.uls': 'US counties (FCC ULS)', 'db.ulsDetail': '{n} callsigns · {d}', 'db.rda': 'Russian districts (RDA)', 'db.rdaDetail': '{n} callsigns, with their dated activity periods', 'db.rdaNote': 'built in', 'db.refLists': 'Award reference lists', 'db.refListsHint': 'Only the awards with an online source appear here; the others are shipped or edited by hand.', 'db.refDetail': '{n} references · {d}', 'db.refUpdated': '{code}: {n} references.', 'db.noRefLists': 'No award has an online reference list.', 'sec.rda': 'Russian districts (RDA)', 'rda.hint': 'The offline district database, and the one bulk operation it feeds.', 'rda.dbTitle': 'District database', 'rda.dbCount': '{n} Russian callsigns, each with the district it operates from and, where it moved, the dated periods it operated from each one. Built into OpsLog — nothing to download.', 'rda.backfillTitle': 'Fill the district on existing QSOs', 'rda.backfillIntro': 'Goes through every contact with a Russian entity and assigns its RDA reference, using the district the station was in ON THE DAY of the contact.', 'rda.useCurrent': 'Also use the current district for stations with no recorded history', 'rda.useCurrentHint': '(true for the great majority — the database records a history precisely for the callsigns that moved — but it is an assumption, not a dated fact)', 'rda.backfillRun': 'Fill districts', 'rda.backfillDone': '{s} Russian QSOs — {d} from a dated record, {c} from the current district, {u} unknown, {k} already had one.', 'rda.cmpTitle': 'Compare the two district sources', 'rda.cmpRunning': 'Comparing…', 'rda.cmpNoConflict': 'No disagreement — both sources say the same district everywhere.', 'rda.cmpNoRussian': 'No Russian contacts to compare.', 'rda.backfillRunning': 'Filling…', 'rda.cmpRun': 'Compare', 'rda.cmpDone': '{s} Russian QSOs — {a} identical, {d} differ, {l} only in the log, {b} only in the database', 'rda.cmpCall': 'Callsign', 'rda.cmpDate': 'Date', 'rda.cmpFromLog': 'Log (CNTY)', 'rda.cmpFromDb': 'RDA database', 'rda.cmpListHint': 'Showing {n} of {d} — click a callsign to open the contact.', 'rda.cmpOpen': 'Open this QSO', 'rda.cmpKind': 'Database', 'rda.cmpDated': 'dated', 'rda.cmpCurrent': 'current', 'rda.neverOverwrites': 'A reference you assigned by hand is never overwritten.', 'rda.cmpKeep': "Keep", 'rda.cmpKeepLog': "Keep the log's district for this contact", 'rda.cmpKeepDb': "Keep the database's district for this contact", 'rda.cmpApply': "Apply {n} decisions", 'rda.cmpAllDb': "keep the database everywhere", 'rda.cmpAllLog': "keep the log everywhere", 'rda.cmpClear': "clear the decisions", 'rda.cmpApplyHint': 'The chosen district is written into the contact — into CNTY and as its award reference — so the disagreement is settled and the contact counts for that district. Settled rows leave the list.',
|
||||
'sec.webpublish': 'Web publishing', 'wpub.hint': 'Publishes your log as a file for a website: a standalone HTML page or a CSV, written locally and optionally uploaded by FTP. It is refreshed when you log a QSO and, if you set an interval, on a timer.', 'wpub.enable': 'Publish the log to a file', 'wpub.fileSection': 'The file', 'wpub.format': 'Format', 'wpub.formatHtml': 'HTML page', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Output folder', 'wpub.browse': 'Browse…', 'wpub.fileName': 'File name', 'wpub.title': 'Page title', 'wpub.titlePh': 'blank = your callsign', 'wpub.count': 'Last N QSOs', 'wpub.every': 'Refresh every', 'wpub.everyHint': 'minutes — 0 = only when a QSO is logged', 'wpub.columns': 'Columns', 'wpub.columnsCount': '{n} of {total} chosen', 'wpub.columnsPick': 'Choose columns…', 'wpub.columnsSearch': 'Search a field…', 'wpub.removeColumn': 'Click to remove', 'wpub.columnsHint': 'Click to add or remove. The order shown here is the order in the file.', 'wpub.ftpEnable': 'Upload by FTP', 'wpub.ftpHost': 'Server / port', 'wpub.ftpUser': 'User', 'wpub.ftpPassword': 'Password', 'wpub.ftpFolder': 'Remote folder', 'wpub.ftpFileName': 'Remote file name', 'wpub.ftpTls': 'Use TLS (FTPS)', 'wpub.publishNow': 'Publish now', 'wpub.testFtp': 'Test connection', 'wpub.lastRun': 'Last run:', 'sec.adifmon': 'ADIF monitor',
|
||||
'sec.foldersync': 'Sync across PCs', 'sync.hint': 'Point every OpsLog at the SAME folder — one your PCs already synchronise (Seafile, OneDrive, Dropbox, a NAS share). Each machine writes what it logs there and reads the others; the databases themselves are never shared.', 'sync.enable': 'Keep my contacts in step across my PCs', 'sync.machine': 'This PC', 'sync.folder': 'Folder', 'sync.choose': 'Choose…', 'sync.state': 'State', 'sync.thisPc': 'This PC', 'sync.lastSync': 'Last check', 'sync.sent': 'Sent', 'sync.received': 'Received', 'sync.never': 'never', 'sync.noPeers': 'No other PC has written to this folder yet.', 'sync.behind': 'new contacts waiting', 'sync.now': 'Synchronise now', 'sync.applied': '{n} change(s) taken from the folder.', 'sync.saved': 'Saved.',
|
||||
'adifmon.hint': 'Watch external ADIF files and import new QSOs automatically — e.g. fldigi logging RTTY, or N1MM/VarAC. Imported QSOs are enriched, de-duplicated and uploaded to your external services just like a QSO logged here.',
|
||||
@@ -636,7 +636,7 @@ const fr: Dict = {
|
||||
'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
|
||||
'sec.ftx': 'Décodages FTx', 'ftx.hint': 'Ce qu’OpsLog fait de lui-même avec le flux de décodages numériques.', 'ftx.enable': 'Appel automatique', 'ftx.enableHint': 'répondre à un décodage sans cliquer', 'ftx.callWhen': 'Appeler une station qui est :', 'ftx.watch': 'Liste de surveillance', 'ftx.watchHint': 'Un indicatif par ligne, jokers acceptés (4S7*, */P). Une station surveillée est appelée avant les critères ci-dessus.', 'ftx.watchOnlyIf': 'mais seulement si elle est aussi :', 'ftx.cooldown': 'Ignorer un indicatif pendant', 'ftx.warn': 'Ceci met ton émetteur en marche sans te demander. Uniquement sur un CQ, jamais pendant que tu émets déjà, une station à la fois, et chaque appel est écrit dans le journal avec sa raison. Stop l’interrompt.', 'ftx.c_dxcc': 'une nouvelle entité DXCC', 'ftx.c_bandmode': 'une nouvelle bande ET un nouveau mode pour l’entité', 'ftx.c_band': 'une nouvelle bande pour l’entité', 'ftx.c_mode': 'un nouveau mode pour l’entité', 'ftx.c_slot': 'un nouveau slot (bande+mode jamais faits ensemble)', 'ftx.c_grid': 'un nouveau carré locator', 'ftx.c_county': 'un nouveau comté US', 'ftx.c_pota': 'un nouveau parc POTA', 'ftx.c_pfx': 'un nouveau préfixe WPX',
|
||||
'sec.bands': 'Bandes', 'sec.satellites': 'Satellites', 'sat.hint': "Les satellites que cette station travaille. Ils sont proposés en liste déroulante sur les champs satellite, par ordre alphabétique.", 'sat.listLabel': 'Un satellite par ligne', 'sat.listHint': "Inscrit dans SAT_NAME exactement tel qu'écrit ici : utilise le nom attendu par LoTW et les diplômes — AO-91, pas AO91. Une liste vide laisse simplement le champ en saisie libre.", 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
||||
'sec.udp': 'Connexions', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US', 'nav.maintenance': 'Maintenance', 'sec.databases': 'Bases de données', 'db.hint': 'Les données de référence qu’OpsLog garde sur disque. Une ligne chacune, avec ce qu’elle contient et sa dernière actualisation.', 'db.update': 'Mettre à jour', 'db.never': 'jamais téléchargée', 'db.cty': 'Fichier pays (cty.dat)', 'db.ctyDetail': '{n} entités · fichier daté du {d}', 'db.clublog': 'Exceptions pays Club Log', 'db.clublogDetail': '{n} exceptions · {d}', 'db.lotwUsers': 'Utilisateurs LoTW', 'db.lotwDetail': '{n} indicatifs · {d}', 'db.scp': 'Super Check Partial (MASTER.SCP)', 'db.scpDetail': '{n} indicatifs · {d}', 'db.uls': 'Comtés US (FCC ULS)', 'db.ulsDetail': '{n} indicatifs · {d}', 'db.rda': 'Districts russes (RDA)', 'db.rdaDetail': '{n} indicatifs, avec leurs périodes d’activité datées', 'db.rdaNote': 'intégrée', 'db.refLists': 'Listes de références des diplômes', 'db.refListsHint': 'Seuls les diplômes ayant une source en ligne apparaissent ici ; les autres sont livrés ou édités à la main.', 'db.refDetail': '{n} références · {d}', 'db.refUpdated': '{code} : {n} références.', 'db.noRefLists': 'Aucun diplôme n’a de liste de références en ligne.', 'sec.rda': 'Districts russes (RDA)', 'rda.hint': 'La base de districts hors ligne, et l’unique opération de masse qu’elle alimente.', 'rda.dbTitle': 'Base des districts', 'rda.dbCount': '{n} indicatifs russes, chacun avec le district d’où il émet et, pour ceux qui ont déménagé, les périodes datées passées dans chacun. Intégrée à OpsLog — rien à télécharger.', 'rda.backfillTitle': 'Renseigner le district sur les QSO existants', 'rda.backfillIntro': 'Parcourt tous les contacts avec une entité russe et attribue leur référence RDA, en utilisant le district où se trouvait la station LE JOUR du contact.', 'rda.useCurrent': 'Utiliser aussi le district actuel pour les stations sans historique connu', 'rda.useCurrentHint': '(vrai pour la grande majorité — la base enregistre un historique justement pour les indicatifs qui ont bougé — mais c’est une supposition, pas un fait daté)', 'rda.backfillRun': 'Renseigner les districts', 'rda.backfillDone': '{s} QSO russes — {d} depuis une période datée, {c} depuis le district actuel, {u} inconnus, {k} en avaient déjà un.', 'rda.cmpTitle': 'Comparer les deux sources de district', 'rda.cmpRunning': 'Comparaison…', 'rda.cmpNoConflict': 'Aucune divergence — les deux sources donnent partout le même district.', 'rda.cmpNoRussian': 'Aucun contact russe à comparer.', 'rda.backfillRunning': 'Remplissage…', 'rda.cmpRun': 'Comparer', 'rda.cmpDone': '{s} QSO russes — {a} identiques, {d} divergents, {l} seulement dans le log, {b} seulement dans la base', 'rda.cmpCall': 'Indicatif', 'rda.cmpDate': 'Date', 'rda.cmpFromLog': 'Log (CNTY)', 'rda.cmpFromDb': 'Base RDA', 'rda.cmpListHint': '{n} affichés sur {d} — cliquer un indicatif ouvre le contact.', 'rda.cmpOpen': 'Ouvrir ce QSO', 'rda.cmpKind': 'Base', 'rda.cmpDated': 'daté', 'rda.cmpCurrent': 'courant', 'rda.neverOverwrites': 'Une référence attribuée à la main n’est jamais écrasée.',
|
||||
'sec.udp': 'Connexions', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US', 'nav.maintenance': 'Maintenance', 'sec.databases': 'Bases de données', 'db.hint': 'Les données de référence qu’OpsLog garde sur disque. Une ligne chacune, avec ce qu’elle contient et sa dernière actualisation.', 'db.update': 'Mettre à jour', 'db.never': 'jamais téléchargée', 'db.cty': 'Fichier pays (cty.dat)', 'db.ctyDetail': '{n} entités · fichier daté du {d}', 'db.clublog': 'Exceptions pays Club Log', 'db.clublogDetail': '{n} exceptions · {d}', 'db.lotwUsers': 'Utilisateurs LoTW', 'db.lotwDetail': '{n} indicatifs · {d}', 'db.scp': 'Super Check Partial (MASTER.SCP)', 'db.scpDetail': '{n} indicatifs · {d}', 'db.uls': 'Comtés US (FCC ULS)', 'db.ulsDetail': '{n} indicatifs · {d}', 'db.rda': 'Districts russes (RDA)', 'db.rdaDetail': '{n} indicatifs, avec leurs périodes d’activité datées', 'db.rdaNote': 'intégrée', 'db.refLists': 'Listes de références des diplômes', 'db.refListsHint': 'Seuls les diplômes ayant une source en ligne apparaissent ici ; les autres sont livrés ou édités à la main.', 'db.refDetail': '{n} références · {d}', 'db.refUpdated': '{code} : {n} références.', 'db.noRefLists': 'Aucun diplôme n’a de liste de références en ligne.', 'sec.rda': 'Districts russes (RDA)', 'rda.hint': 'La base de districts hors ligne, et l’unique opération de masse qu’elle alimente.', 'rda.dbTitle': 'Base des districts', 'rda.dbCount': '{n} indicatifs russes, chacun avec le district d’où il émet et, pour ceux qui ont déménagé, les périodes datées passées dans chacun. Intégrée à OpsLog — rien à télécharger.', 'rda.backfillTitle': 'Renseigner le district sur les QSO existants', 'rda.backfillIntro': 'Parcourt tous les contacts avec une entité russe et attribue leur référence RDA, en utilisant le district où se trouvait la station LE JOUR du contact.', 'rda.useCurrent': 'Utiliser aussi le district actuel pour les stations sans historique connu', 'rda.useCurrentHint': '(vrai pour la grande majorité — la base enregistre un historique justement pour les indicatifs qui ont bougé — mais c’est une supposition, pas un fait daté)', 'rda.backfillRun': 'Renseigner les districts', 'rda.backfillDone': '{s} QSO russes — {d} depuis une période datée, {c} depuis le district actuel, {u} inconnus, {k} en avaient déjà un.', 'rda.cmpTitle': 'Comparer les deux sources de district', 'rda.cmpRunning': 'Comparaison…', 'rda.cmpNoConflict': 'Aucune divergence — les deux sources donnent partout le même district.', 'rda.cmpNoRussian': 'Aucun contact russe à comparer.', 'rda.backfillRunning': 'Remplissage…', 'rda.cmpRun': 'Comparer', 'rda.cmpDone': '{s} QSO russes — {a} identiques, {d} divergents, {l} seulement dans le log, {b} seulement dans la base', 'rda.cmpCall': 'Indicatif', 'rda.cmpDate': 'Date', 'rda.cmpFromLog': 'Log (CNTY)', 'rda.cmpFromDb': 'Base RDA', 'rda.cmpListHint': '{n} affichés sur {d} — cliquer un indicatif ouvre le contact.', 'rda.cmpOpen': 'Ouvrir ce QSO', 'rda.cmpKind': 'Base', 'rda.cmpDated': 'daté', 'rda.cmpCurrent': 'courant', 'rda.neverOverwrites': 'Une référence attribuée à la main n’est jamais écrasée.', 'rda.cmpKeep': "Garder", 'rda.cmpKeepLog': "Garder le district du log pour ce contact", 'rda.cmpKeepDb': "Garder le district de la base pour ce contact", 'rda.cmpApply': "Appliquer {n} décisions", 'rda.cmpAllDb': "garder la base partout", 'rda.cmpAllLog': "garder le log partout", 'rda.cmpClear': "effacer les décisions", 'rda.cmpApplyHint': "Le district choisi est écrit dans le contact — dans CNTY et comme référence de diplôme — donc la divergence est réglée et le contact compte pour ce district. Les lignes réglées quittent la liste.",
|
||||
'sec.webpublish': 'Publication web', 'wpub.hint': "Publie ton journal dans un fichier destiné à un site web : une page HTML autonome ou un CSV, écrit en local et envoyé par FTP si tu le souhaites. Il est rafraîchi à chaque QSO enregistré et, si tu règles un intervalle, périodiquement.", 'wpub.enable': 'Publier le journal dans un fichier', 'wpub.fileSection': 'Le fichier', 'wpub.format': 'Format', 'wpub.formatHtml': 'Page HTML', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Dossier de sortie', 'wpub.browse': 'Parcourir…', 'wpub.fileName': 'Nom du fichier', 'wpub.title': 'Titre de la page', 'wpub.titlePh': 'vide = ton indicatif', 'wpub.count': 'N derniers QSO', 'wpub.every': 'Rafraîchir toutes les', 'wpub.everyHint': 'minutes — 0 = seulement à chaque QSO', 'wpub.columns': 'Colonnes', 'wpub.columnsCount': '{n} sur {total} choisis', 'wpub.columnsPick': 'Choisir les colonnes…', 'wpub.columnsSearch': 'Chercher un champ…', 'wpub.removeColumn': 'Cliquer pour retirer', 'wpub.columnsHint': 'Clique pour ajouter ou retirer. L ordre affiché ici est celui du fichier.', 'wpub.ftpEnable': 'Envoyer par FTP', 'wpub.ftpHost': 'Serveur / port', 'wpub.ftpUser': 'Utilisateur', 'wpub.ftpPassword': 'Mot de passe', 'wpub.ftpFolder': 'Dossier distant', 'wpub.ftpFileName': 'Nom du fichier distant', 'wpub.ftpTls': 'Utiliser TLS (FTPS)', 'wpub.publishNow': 'Publier maintenant', 'wpub.testFtp': 'Tester la connexion', 'wpub.lastRun': 'Dernière exécution :', 'sec.adifmon': 'Moniteur ADIF',
|
||||
'sec.foldersync': 'Synchro entre PC', 'sync.hint': 'Fais pointer chaque OpsLog vers le MÊME dossier — un dossier que tes PC synchronisent déjà (Seafile, OneDrive, Dropbox, un partage NAS). Chaque machine y écrit ce qu’elle enregistre et lit celui des autres ; les bases de données, elles, ne sont jamais partagées.', 'sync.enable': 'Garder mes contacts à jour sur tous mes PC', 'sync.machine': 'Ce PC', 'sync.folder': 'Dossier', 'sync.choose': 'Choisir…', 'sync.state': 'État', 'sync.thisPc': 'Ce PC', 'sync.lastSync': 'Dernière vérification', 'sync.sent': 'Envoyés', 'sync.received': 'Reçus', 'sync.never': 'jamais', 'sync.noPeers': 'Aucun autre PC n’a encore écrit dans ce dossier.', 'sync.behind': 'nouveaux contacts en attente', 'sync.now': 'Synchroniser maintenant', 'sync.applied': '{n} changement(s) repris du dossier.', 'sync.saved': 'Enregistré.',
|
||||
'adifmon.hint': "Surveille des fichiers ADIF externes et importe les nouveaux QSO automatiquement — ex. fldigi en RTTY, ou N1MM/VarAC. Les QSO importés sont enrichis, dédoublonnés et envoyés à tes services externes comme un QSO loggé ici.",
|
||||
|
||||
@@ -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.15';
|
||||
export const APP_VERSION = '0.26.17';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+2
@@ -65,6 +65,8 @@ export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function ApplyAwardUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function ApplyRDAChoices(arg1:Array<main.RDAChoice>):Promise<main.RDAApplyResult>;
|
||||
|
||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||
|
||||
export function AudioApplyLevels(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
@@ -70,6 +70,10 @@ export function ApplyAwardUpdate(arg1) {
|
||||
return window['go']['main']['App']['ApplyAwardUpdate'](arg1);
|
||||
}
|
||||
|
||||
export function ApplyRDAChoices(arg1) {
|
||||
return window['go']['main']['App']['ApplyRDAChoices'](arg1);
|
||||
}
|
||||
|
||||
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
@@ -3349,6 +3349,36 @@ export namespace main {
|
||||
this.team_last60 = source["team_last60"];
|
||||
}
|
||||
}
|
||||
export class RDAApplyResult {
|
||||
applied: number;
|
||||
failed: number;
|
||||
message: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RDAApplyResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.applied = source["applied"];
|
||||
this.failed = source["failed"];
|
||||
this.message = source["message"];
|
||||
}
|
||||
}
|
||||
export class RDAChoice {
|
||||
qso_id: number;
|
||||
district: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RDAChoice(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.qso_id = source["qso_id"];
|
||||
this.district = source["district"];
|
||||
}
|
||||
}
|
||||
export class RDAConflict {
|
||||
qso_id: number;
|
||||
callsign: string;
|
||||
|
||||
@@ -67,6 +67,9 @@ type TCI struct {
|
||||
// never sends TX_ENABLE at all, from being treated as refusing: without a
|
||||
// word from the radio we key and let it decide.
|
||||
txAllowed bool
|
||||
// txSource is the TRX third argument: "tci" while OpsLog has audio to send,
|
||||
// empty for the operator's microphone. See SetPTT.
|
||||
txSource string
|
||||
// drive is the radio's transmit drive, 0-100. Kept because a quiet
|
||||
// transmission has two possible causes — our level or the radio's — and a
|
||||
// log that names both settles it in one line instead of an evening.
|
||||
@@ -344,9 +347,49 @@ func (t *TCI) SetPTT(on bool) error {
|
||||
"check the frequency is inside a transmit band and that TX is enabled in ExpertSDR")
|
||||
}
|
||||
}
|
||||
// THE THIRD ARGUMENT NAMES THE AUDIO SOURCE, and it is the whole answer to
|
||||
// "why does the radio ignore what I send it".
|
||||
//
|
||||
// TCI 2.0 §TRX: "The signal for transmitting is always taken from the
|
||||
// microphone selected in the ExpertSDR3. If a third-party software connected
|
||||
// via TCI wants to transmit its audio signal, you must specify the third
|
||||
// argument - TCI." Without it the radio never sends a single chrono frame,
|
||||
// whatever the mode and whatever is configured in its window — which is
|
||||
// exactly what a night of experiments showed and misread as "digital modes
|
||||
// only".
|
||||
//
|
||||
// Sent only when a transmission is ours to feed. A plain trx keeps the
|
||||
// operator's own microphone, which is what every other PTT in OpsLog means.
|
||||
t.mu.Lock()
|
||||
src := t.txSource
|
||||
t.mu.Unlock()
|
||||
if on && src != "" {
|
||||
return t.send(fmt.Sprintf("trx:0,true,%s;", src))
|
||||
}
|
||||
return t.send(fmt.Sprintf("trx:0,%t;", on))
|
||||
}
|
||||
|
||||
// SetTXAudioSource says where the radio should take its transmit audio from
|
||||
// while OpsLog keys it: "tci" for the stream this program sends, "" for the
|
||||
// microphone the operator chose in ExpertSDR3.
|
||||
//
|
||||
// Set from the audio settings — it follows the "To radio" device — so keying
|
||||
// for a voice message and keying for anything else behave differently on
|
||||
// purpose: only the first one takes the audio away from the microphone.
|
||||
func (t *TCI) SetTXAudioSource(src string) {
|
||||
t.mu.Lock()
|
||||
changed := t.txSource != src
|
||||
t.txSource = src
|
||||
t.mu.Unlock()
|
||||
if changed {
|
||||
if src == "" {
|
||||
debugLog.Printf("TCI: transmit audio will come from the radio's own microphone")
|
||||
} else {
|
||||
debugLog.Printf("TCI: transmit audio will be taken from %s when OpsLog keys the radio", src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send writes a command to the WebSocket (one writer at a time).
|
||||
func (t *TCI) send(cmd string) error {
|
||||
t.mu.Lock()
|
||||
|
||||
@@ -136,6 +136,13 @@ func (t *TCI) StartTCIAudio(rx, rate int) error {
|
||||
if err := t.send(fmt.Sprintf("audio_samplerate:%d;", rate)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Said rather than assumed. float32 and two channels are the documented
|
||||
// defaults and what this radio streams, but a default is a thing another
|
||||
// program can have changed — they share the radio, not just the protocol —
|
||||
// and a stream arriving in a format the decoder was not expecting is heard
|
||||
// as noise, not as a mistake.
|
||||
_ = t.send("audio_stream_sample_type:float32;")
|
||||
_ = t.send("audio_stream_channels:2;")
|
||||
return t.send(fmt.Sprintf("audio_start:%d;", rx))
|
||||
}
|
||||
|
||||
|
||||
@@ -88,10 +88,13 @@ func (t *TCI) serveChrono(rate, samples int) {
|
||||
}
|
||||
payload := feed(samples)
|
||||
if payload == nil {
|
||||
// Answered with silence rather than left unanswered. TCI 2.0 §3.4: "the
|
||||
// client may not send a response or may send a signal with zero counts,
|
||||
// which corresponds to no signal - this option is preferable."
|
||||
payload = make([]byte, samples*4)
|
||||
t.audio.mu.Lock()
|
||||
t.audio.txShort++
|
||||
t.audio.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if rate <= 0 {
|
||||
rate = 48000
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package qso
|
||||
|
||||
import "testing"
|
||||
|
||||
// The colour of a matrix cell is a claim about what the operator still needs,
|
||||
// and it was wrong for several releases: a callsign worked and not confirmed
|
||||
// outranked an entity CONFIRMED on the same band and mode, so a slot that was
|
||||
// finished showed as unfinished.
|
||||
//
|
||||
// Reported by VK4DX with the case that names itself: YB confirmed on 20m
|
||||
// digital, shown blue, because one unconfirmed YB station had also been worked
|
||||
// there.
|
||||
func TestBandStatusConfirmedOutranksWorked(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
callWorked, callConfirmed, entityConfirm bool
|
||||
want string
|
||||
}{
|
||||
{"entity worked only", false, false, false, "dxcc_w"},
|
||||
{"this call worked, nothing confirmed", true, false, false, "call_w"},
|
||||
// The regression, in one line.
|
||||
{"entity confirmed, this call worked but not confirmed", true, false, true, "dxcc_c"},
|
||||
{"entity confirmed, this call not worked here", false, false, true, "dxcc_c"},
|
||||
{"this call confirmed", true, true, true, "call_c"},
|
||||
// A confirmed contact with this call implies it was worked, but the flags
|
||||
// arrive from separate SQL aggregates and nothing guarantees the pair.
|
||||
{"call confirmed without the worked flag", false, true, true, "call_c"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := bandStatusNames[bandStatusCode(c.callWorked, c.callConfirmed, c.entityConfirm)]
|
||||
if got != c.want {
|
||||
t.Errorf("bandStatusCode(worked=%v, callConf=%v, entityConf=%v) = %s, want %s",
|
||||
c.callWorked, c.callConfirmed, c.entityConfirm, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The ladder itself, stated once: every confirmed code must beat every worked
|
||||
// code. A future edit that reorders the constants fails here rather than in a
|
||||
// screenshot from an operator.
|
||||
func TestBandStatusLadderPutsConfirmedAbove(t *testing.T) {
|
||||
for _, worked := range []int{stDxccW, stCallW} {
|
||||
for _, confirmed := range []int{stDxccC, stCallC} {
|
||||
if confirmed <= worked {
|
||||
t.Errorf("%s (%d) does not outrank %s (%d)",
|
||||
bandStatusNames[confirmed], confirmed, bandStatusNames[worked], worked)
|
||||
}
|
||||
}
|
||||
}
|
||||
if stCallC <= stDxccC {
|
||||
t.Error("call_c must outrank dxcc_c: the callsign is the more specific claim")
|
||||
}
|
||||
if stCallW <= stDxccW {
|
||||
t.Error("call_w must outrank dxcc_w for the same reason")
|
||||
}
|
||||
}
|
||||
+58
-19
@@ -1895,7 +1895,8 @@ type WorkedBefore struct {
|
||||
|
||||
// Status grid driving the band×class matrix in the UI. One entry per
|
||||
// (band, class) where ANY QSO exists in this DXCC. Only the highest
|
||||
// status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).
|
||||
// status for that cell is kept (call_c > dxcc_c > call_w > dxcc_w —
|
||||
// confirmed outranks worked).
|
||||
BandStatus []BandStatus `json:"band_status"`
|
||||
}
|
||||
|
||||
@@ -1906,6 +1907,48 @@ type BandStatus struct {
|
||||
Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
|
||||
}
|
||||
|
||||
// Band-status codes, lowest first. The ORDER is the rule: a cell shows the
|
||||
// highest that applies.
|
||||
const (
|
||||
stDxccW = iota // the entity was worked on this slot
|
||||
stCallW // …and this callsign was one of them
|
||||
stDxccC // the entity is CONFIRMED here
|
||||
stCallC // …and by this callsign
|
||||
)
|
||||
|
||||
// bandStatusNames maps those codes to what the UI colours by.
|
||||
var bandStatusNames = [...]string{"dxcc_w", "call_w", "dxcc_c", "call_c"}
|
||||
|
||||
// bandStatusCode picks the status of one cell of the band × mode matrix.
|
||||
//
|
||||
// CONFIRMED OUTRANKS WORKED, and that is the whole of it. The ladder used to
|
||||
// run call_c > call_w > dxcc_c > dxcc_w, so a callsign worked and not confirmed
|
||||
// beat an entity confirmed on the same slot: an operator with YB confirmed on
|
||||
// 20m digital saw that cell as "worked, not confirmed" because he had also
|
||||
// worked one unconfirmed YB station there. The grid answers "what do I still
|
||||
// need", and a confirmed entity needs nothing, whoever was worked afterwards.
|
||||
//
|
||||
// Taken as a MAXIMUM rather than as a run of assignments, which is how the
|
||||
// later test came to overwrite the earlier one in the first place.
|
||||
func bandStatusCode(callWorked, callConfirmed, entityConfirmed bool) int {
|
||||
code := stDxccW // there is a row at all ⇒ the entity was worked here
|
||||
raise := func(c int) {
|
||||
if c > code {
|
||||
code = c
|
||||
}
|
||||
}
|
||||
if callWorked {
|
||||
raise(stCallW)
|
||||
}
|
||||
if entityConfirmed {
|
||||
raise(stDxccC)
|
||||
}
|
||||
if callConfirmed {
|
||||
raise(stCallC)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// modeClass collapses ADIF modes into the three buckets DXers care about.
|
||||
// Anything not voice and not CW is treated as digital.
|
||||
func modeClass(mode string) string {
|
||||
@@ -2167,7 +2210,18 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
|
||||
// ---- Per-(band, class) status grid ----
|
||||
// One pass over every distinct (band, mode) in the DXCC, aggregating
|
||||
// "did this call work it?" and "was anything confirmed?" via MAX.
|
||||
// Status precedence: call_c > call_w > dxcc_c > dxcc_w.
|
||||
// Status precedence: CONFIRMED OUTRANKS WORKED — call_c > dxcc_c > call_w >
|
||||
// dxcc_w.
|
||||
//
|
||||
// It used to run call_c > call_w > dxcc_c > dxcc_w, which made a call worked
|
||||
// and not confirmed outrank an entity confirmed on the same slot. Reported
|
||||
// from a real log: YB confirmed on 20m digital showed BLUE, because that
|
||||
// operator had also worked one unconfirmed YB station there. The cell said
|
||||
// "not confirmed" about a slot that is confirmed.
|
||||
//
|
||||
// The grid answers "what do I still need on this band and mode", and for
|
||||
// that question confirmation is the axis that matters: a confirmed entity
|
||||
// needs nothing, whoever else was worked afterwards.
|
||||
// Filter NULL/empty band+mode rows — they'd create a NULL group key
|
||||
// that Scan into *string can't handle and would error out the whole
|
||||
// WorkedBefore call, blanking the matrix in the UI.
|
||||
@@ -2188,12 +2242,6 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
|
||||
return wb, fmt.Errorf("band status: %w", err)
|
||||
}
|
||||
type cellKey struct{ band, class string }
|
||||
const (
|
||||
stDxccW = 0
|
||||
stDxccC = 1
|
||||
stCallW = 2
|
||||
stCallC = 3
|
||||
)
|
||||
best := map[cellKey]int{}
|
||||
for statusRows.Next() {
|
||||
var band, mode string
|
||||
@@ -2202,23 +2250,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int,
|
||||
statusRows.Close()
|
||||
return wb, fmt.Errorf("scan band status: %w", err)
|
||||
}
|
||||
code := stDxccW // row exists ⇒ entity worked at minimum
|
||||
if dxccConfirmed == 1 {
|
||||
code = stDxccC
|
||||
}
|
||||
if callW == 1 {
|
||||
code = stCallW
|
||||
}
|
||||
if callC == 1 {
|
||||
code = stCallC
|
||||
}
|
||||
code := bandStatusCode(callW == 1, callC == 1, dxccConfirmed == 1)
|
||||
k := cellKey{band: band, class: modeClass(mode)}
|
||||
if cur, ok := best[k]; !ok || code > cur {
|
||||
best[k] = code
|
||||
}
|
||||
}
|
||||
statusRows.Close()
|
||||
codeStr := [...]string{"dxcc_w", "dxcc_c", "call_w", "call_c"}
|
||||
codeStr := bandStatusNames
|
||||
for k, code := range best {
|
||||
wb.BandStatus = append(wb.BandStatus, BandStatus{
|
||||
Band: k.band, Class: k.class, Status: codeStr[code],
|
||||
|
||||
@@ -44,7 +44,11 @@ import (
|
||||
// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
|
||||
// package stays testable without a radio and without importing internal/cat.
|
||||
type Rig interface {
|
||||
Freq() int64 // current TX frequency in Hz, 0 if unknown
|
||||
// Freq is the frequency of the CURRENT VFO — where the operator is
|
||||
// listening. Hamlib's "f" means the VFO in use, not the transmit one; the
|
||||
// split TX frequency is a separate question, asked with "i". Answering "f"
|
||||
// with the TX frequency invites a client to write it back as the dial.
|
||||
Freq() int64 // current (RX) frequency in Hz, 0 if unknown
|
||||
Mode() string // ADIF mode (SSB, CW, FT8…)
|
||||
Split() (bool, int64) // split on?, and the other VFO's frequency
|
||||
SetFreq(hz int64) error
|
||||
|
||||
@@ -148,3 +148,73 @@ func isHamlogConfirmed(v string) bool {
|
||||
v = strings.ToUpper(strings.TrimSpace(v))
|
||||
return v != "" && v != "N" && v != "NO"
|
||||
}
|
||||
|
||||
// RDAChoice is one arbitrated contact: which district the operator decided is
|
||||
// right for that QSO.
|
||||
type RDAChoice struct {
|
||||
QSOID int64 `json:"qso_id"`
|
||||
District string `json:"district"`
|
||||
}
|
||||
|
||||
// RDAApplyResult reports what an arbitration did.
|
||||
type RDAApplyResult struct {
|
||||
Applied int `json:"applied"`
|
||||
Failed int `json:"failed"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ApplyRDAChoices writes the chosen district onto each contact — into CNTY and
|
||||
// as the award reference, both.
|
||||
//
|
||||
// The first version wrote only the award override, to keep the imported CNTY as
|
||||
// a record of what HAMLOG said. That was wrong in the way that matters: CNTY is
|
||||
// what the comparison READS, so the contact went on disagreeing for ever. The
|
||||
// operator settled a hundred contacts, ran the comparison again and got the
|
||||
// same hundred rows — the decision had no visible effect anywhere, which is
|
||||
// indistinguishable from a button that does nothing.
|
||||
//
|
||||
// So the chosen district lands in CNTY, where it settles the disagreement, and
|
||||
// in the award reference, where it decides what the contact counts for. This is
|
||||
// the operator's own log: correcting a field in it is the point of the exercise,
|
||||
// not a loss of evidence.
|
||||
func (a *App) ApplyRDAChoices(choices []RDAChoice) (RDAApplyResult, error) {
|
||||
var res RDAApplyResult
|
||||
if a.qso == nil {
|
||||
return res, fmt.Errorf("db not initialized")
|
||||
}
|
||||
for _, c := range choices {
|
||||
district := strings.ToUpper(strings.TrimSpace(c.District))
|
||||
if !rdaDistrictRe.MatchString(district) {
|
||||
res.Failed++
|
||||
applog.Printf("rda apply: qso %d — %q is not a district reference", c.QSOID, c.District)
|
||||
continue
|
||||
}
|
||||
q, err := a.qso.GetByID(a.ctx, c.QSOID)
|
||||
if err != nil {
|
||||
res.Failed++
|
||||
applog.Printf("rda apply: qso %d could not be read: %v", c.QSOID, err)
|
||||
continue
|
||||
}
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
}
|
||||
q.County = district
|
||||
q.Extras[award.ManualRefsKey] = setOverrideRef(q.Extras[award.ManualRefsKey], "RDA", district)
|
||||
if err := a.qso.Update(a.ctx, q); err != nil {
|
||||
res.Failed++
|
||||
applog.Printf("rda apply: qso %d update failed: %v", c.QSOID, err)
|
||||
continue
|
||||
}
|
||||
res.Applied++
|
||||
}
|
||||
if res.Applied > 0 {
|
||||
// The award totals were computed from the old answers.
|
||||
a.invalidateAwardStats()
|
||||
}
|
||||
applog.Printf("rda apply: %d contacts settled, %d failed", res.Applied, res.Failed)
|
||||
res.Message = fmt.Sprintf("%d settled", res.Applied)
|
||||
if res.Failed > 0 {
|
||||
res.Message += fmt.Sprintf(", %d failed (see the log)", res.Failed)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.26.15"
|
||||
appVersion = "0.26.17"
|
||||
|
||||
// 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