piLog - how it works (threading and data flow)
================================================
Based on pilog.py (~3,300 lines, single file, PyQt6). Line numbers are approximate.

OVERVIEW
--------
piLog is a PyQt6 amateur-radio logger. It connects to a radio, looks up callsigns,
writes QSOs to ADIF files, and uploads them to QRZ Logbook and eQSL. It also shows
a DX-cluster map and a statistics window.

THREADING MODEL
---------------
One GUI thread (Qt main thread). Every network or blocking job runs in a plain
threading.Thread with daemon=True, so none of them can keep the process alive on exit.

Threads never touch widgets. They talk to the GUI through one module-level
Bus(QObject) instance called `bus` (pilog.py:287-300). Signals:
  rig, rig_state, vfo, lookup, lookup_error, status, spot, spot_located,
  dxc_state, tile_ready, cty_ready
Emitting a Qt signal from a non-GUI thread is queued into the GUI thread's event
loop, so GUI slots run safely on the main thread.

Threads:
  RigClient (piHPSDR) / FlrigClient
      Long-lived. MainWindow._apply_rig swaps them when settings change.
      Polls the rig.
  DXClusterClient
      Long-lived, started from the dxc_enabled setting. Telnet reader for DX spots.
  SpotLocator
      Long-lived, with a queue.Queue of DX calls. Resolves spot callsigns to
      lat/lon through QRZ.
  TileFetcher
      Two worker threads on a LifoQueue (newest on-screen tile first).
      Downloads OSM map tiles.
  QRZ lookup
      One short-lived thread per lookup (the work() closure, ~line 1690).
  Uploader._work
      One short-lived thread per save. QRZ Logbook and eQSL uploads.
  CallDB.download
      One-shot. Fetches cty.dat.

There is no thread pool; short-lived threads are created on demand.

Shared state and ordering:
  - Uploader holds a threading.Lock, so overlapping saves can't corrupt the
    retry-queue files.
  - GUI-to-thread commands go through queues: RigClient.cmds (SimpleQueue),
    FlrigClient has its own, DXClusterClient.outq (Queue).
  - The poll thread drains its queue itself in _flush. The GUI never writes to
    the rig socket directly, so there are no interleaved writes.
  - Threads stop via a stop_flag (threading.Event). They sleep with
    stop_flag.wait(n) rather than time.sleep, so a stop takes effect immediately.
  - _emit / _safe_emit skip emitting once stopping has begun. This avoids touching
    `bus` after Qt has deleted it.
  - closeEvent queues a MOX release (ZZTX0;), stops all threads, then joins them
    with a 3 s timeout. The join happens before Qt deletes `bus`. The rig thread
    flushes the queued MOX-off before exiting, so the radio is never left
    transmitting.

DATA FLOW
---------
1. Rig -> GUI (piHPSDR, TCP 19090)
   RigClient.run (lines 356-405) holds a connection opened with
   socket.create_connection and runs a request/reply loop.
     - _cmd sends a CAT string such as "FR;" and reads until the reply ends in ";".
     - On any OSError it emits rig_state(False), waits 3 s, and reconnects.
   Fast path, every ~0.1 s:
     - Sends FR; (active receiver), ZZFA; and ZZFB; (VFO frequencies),
       ZZTX; (MOX) and FT; (split).
     - Emits bus.vfo only when that tuple changes.
   Slow path, every 5th pass (~0.5 s):
     - Sends ZZMD; or ZZME; for the mode (depending on the active receiver),
       and SA; for the SAT state.
     - Emits a full bus.rig dict: frequency, mode (mapped through RIG_MODES),
       SAT, MOX, split and both VFO frequencies.
   FLRig works the same way over XML-RPC (xmlrpc.client.ServerProxy, default port
   12345, with a timeout transport) and emits the same dict shapes. The GUI does
   not care which backend is active.

2. GUI -> rig
   MOX, tuning from a clicked spot, etc. call RigClient.send, which puts the
   string on `cmds`. The poll thread sends it at the top of its next loop
   (worst case about 0.1 s of added latency).

3. Rig -> QSO entry
   MainWindow.on_rig receives bus.rig and bus.vfo and passes them to each
   QSOEntry.apply_rig. That fills frequency, mode, band and the Satellite
   checkbox only for fields the user has not edited (the self.dirty set).
   When split is on, the *_split_vfo setting picks which VFO's frequency is used.

4. Callsign lookup
   When a callsign is typed, do_lookup spawns a thread that tries, in order:
     1. QRZ.cached - a 60-day JSON cache
     2. lookup_from_logs - the newest logged QSO for that call
     3. QRZ XML via requests.get with a session key (only if qrz_enabled)
   The result returns as bus.lookup(entry, dict). The entry fills name, QTH, grid
   etc., again only into untouched fields. update_beam then computes distance and
   bearing from the Grid field and your own locator (display only).

5. Save -> disk -> upload
   1. QSOEntry.save calls append_adif, which always writes the local file first.
      The file is chosen by category_for(power, mode, satellite).
   2. It then calls Uploader.submit(record), which starts a thread.
   3. _work checks upload_targets. A disabled service, or one without
      credentials, is skipped: no request and no queue write.
   4. For each enabled service, _work reads the retry queue file and appends the
      new record, then tries to upload every pending record:
        - QRZ: requests.post to the Logbook API
        - eQSL: requests.post to importADIF.cfm
   5. Failed records go back to qrz_queue.adi / eqsl_queue.adi; successes drop
      out. The result is reported via bus.status.
   6. MainWindow calls submit(None) at startup to retry anything left in the queues.

6. DX cluster
   DXClusterClient.run (lines 1895-1945) opens a telnet socket with a 0.5 s recv
   timeout. It:
     - filters telnet negotiation bytes and splits the data into lines
     - parses each line with parse_spot
     - auto-answers the login: and password: prompts
     - emits bus.spot for each spot
     - between reads, drains outq to send spots you have posted; the queue is
       recreated on every reconnect so a stale spot is never sent late
   GUI side: spots are added to SpotMapView. SpotLocator resolves each spot's
   callsign (QRZ lat/lon, falling back to grid) and emits spot_located. Clicking
   a pin fills an entry and retunes the active VFO through RigClient.send
   (ZZFA/ZZMD).

7. Maps and stats
   - TileMapView requests tiles through TileFetcher. Worker threads download them,
     cache them in ~/.config/piLog/tiles, and emit bus.tile_ready so the view
     repaints.
   - StatsWindow calls compute_stats synchronously on the GUI thread each time it
     opens or refreshes (parses every ADIF file). The only background part is the
     cty.dat download, which emits cty_ready to refresh the window.
   - search_logs also runs synchronously and re-parses all logs on every query.

STORAGE
-------
Everything lives in ~/.config/piLog/:
  settings.json (chmod 600, passwords in plain text), qrz_cache.json, cty.dat,
  qrz_queue.adi, eqsl_queue.adi, upload.log, tiles/
QSOs themselves are plain ADIF files at the configured per-category paths. They
are the single source of truth; uploads and stats are derived from them.

