Repository navigation
Expand file tree
/
Copy pathcat_server.py
More file actions
3542 lines (3215 loc) · 166 KB
/
Copy pathcat_server.py
File metadata and controls
3542 lines (3215 loc) · 166 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
cat_server.py
================
A small simulated-SDR backend for `cat_gui.py`.
It listens on a TCP socket and speaks a simple newline-delimited JSON
protocol:
* The GUI sends one JSON object per line, each with a "cmd" field, e.g.
{"cmd": "set_freq", "hz": 14195000}
{"cmd": "set_tune_freq", "hz": 14205000}
{"cmd": "set_mode", "mode": "USB"}
{"cmd": "set_agc", "mode": "medium"}
{"cmd": "set_agc_thresh", "value": -100.0}
{"cmd": "set_filter", "lo": 100, "hi": 2800}
{"cmd": "set_rf_gain", "value": 20.0}
{"cmd": "set_volume", "value": 80.0}
{"cmd": "set_squelch", "value": -130.0}
{"cmd": "set_nb", "enabled": true}
{"cmd": "set_nr", "enabled": true}
{"cmd": "set_nbrf","enabled": true}
{"cmd": "set_nbif","enabled": true}
{"cmd": "set_afc", "enabled": true}
{"cmd": "set_anf", "enabled": true}
{"cmd": "set_notch","enabled": true}
{"cmd": "set_mute","enabled": true}
{"cmd": "set_zoom","value": 2}
{"cmd": "set_spec_ref","box": "rf","value": -10}
{"cmd": "set_spec_ave","box": "rf","value": 4}
{"cmd": "get_sample_rates"}
{"cmd": "set_sample_rate", "value": 192000}
{"cmd": "ui_button","name": "Full Screen"}
{"cmd": "transport","action": "\u25b6"}
{"cmd": "user_text", "index": 1, "text": "CQ CQ DE TEST"}
{"cmd": "get_memories", "position": "LO A"}
{"cmd": "save_memory", "position": "LO A", "index": 0,
"label": "40M SSB", "freq": 7185000}
{"cmd": "start"}
{"cmd": "stop"}
{"cmd": "hello"}
Every command gets an immediate reply:
{"resp": "ok", "state": {...full radio state...}}
* While "running", the server pushes ~10 updates/second of the form:
{
"type": "data",
"f_start": <Hz>, "f_stop": <Hz>, "spectrum": [dBm, ...],
"af_range": 3000, "af_spectrum": [dBm, ...],
"smeter_dbm": <float>, "smeter_text": "S7",
"squelch_open": <bool>,
"state": {...}
}
This is enough for the GUI to draw the RF/AF spectra + waterfalls, move the
S-meter needle, light the squelch LED, and keep every control (frequency,
mode, AGC, filter, sliders, zoom, start/stop) in sync.
Configuration files
--------------------
Settings are split across two TOML files, each self-creating with sane
defaults on first run and self-correcting (missing keys get added back at
their default value) on every later run:
* cat_server.toml (--config PATH) -- [server], [audio], [devices].
This is the transport configuration (TCP host/port the CAT socket
listens on, UDP/RTP audio port, whether audio is disabled) plus the
*list* of selectable device profiles shown in the GUI's Device dialog.
[devices] only holds label_N / config_N pairs here -- it does not hold
any buttons itself.
* cat_device.toml (--device-config PATH) -- [user_buttons], [user_mods],
[rf_usr_btns], [sdr], [antenna], [bandwidth]. This is one device profile's GUI configuration:
its user-defined buttons, user-defined modulation buttons, RF user
buttons, selectable SDR sample rates, and antenna port definitions. No [devices] section here.
[sdr] holds sample_rate (the rate applied when this profile is loaded)
and sample_rates (a comma-separated list of the rates selectable from
the GUI's Sample Rate dialog) and allowed_bands (the device-level band
restriction). Pressing "Sample Rate" in the GUI sends
{"cmd": "get_sample_rates"}; the server replies with the list defined
here. Choosing one sends {"cmd": "set_sample_rate", "value": N}, which
is only accepted if N is one of the configured sample_rates.
[antenna] holds label_N and allowed_bands_N (N=1..10) for the antenna
port selector shown in the GUI. Each antenna can carry its own band
restriction; an empty allowed_bands_N inherits the device-level
[sdr].allowed_bands restriction.
Each entry in cat_server.toml's [devices] section (config_N) names a
path to a *cat_device.toml-like file* for that profile -- i.e. a file
with this same [user_buttons]/[user_mods]/[rf_usr_btns]/[sdr] structure
(no nested [devices]). When the GUI sends
{"cmd": "select_device", "index": N}, that file is loaded (and, like
cat_device.toml, auto-created/self-corrected if needed) and its
buttons (and sample rates) replace the running radio's. The device
*list* itself doesn't change when you switch devices -- it's fixed for
the session, read once from cat_server.toml at startup.
CLI flags always override whichever config file would otherwise supply that
value; the config files only supply defaults for flags that weren't passed
on the command line. (There are no CLI flags for [devices]; it's TOML-only.)
Frequency memories
-------------------
Each device profile has its own independent set of frequency memories: 20
slots (M1..M20) for each of the three frequency rows "LO A", "LO B", and
"Tune". They are stored as JSON, in a file next to that device's own config
file (same name, ".memories.json" suffix) -- so switching devices via
"select_device" also switches which memory bank is active, and memories
never leak between devices. The file is rewritten immediately every time a
slot is saved, not just on shutdown.
{"cmd": "get_memories", "position": "LO A"}
-> {"type": "memory_list", "position": "LO A",
"memories": [{"label": "40M SSB", "freq": 7185000.0}, ...] } (20 entries)
{"cmd": "save_memory", "position": "LO A", "index": 0,
"label": "40M SSB", "freq": 7185000}
-> {"type": "memory_list", "position": "LO A", "memories": [...] }
(also written to that device's .memories.json file right away)
User-defined buttons
--------------------
Up to 14 user-defined buttons (N = 1..14) can be configured via CLI flags and
are advertised to the GUI in the "hello" response and in every "state" dict
as a "user_buttons" list:
--user-button-label-N TEXT Label for user button N (max 7 chars)
--user-button-type-N TYPE "normal" (momentary) or "push" (push-push /
toggle). Default: "normal"
A button the GUI sends becomes:
{"cmd": "user_button", "index": N} (normal button press)
{"cmd": "user_button", "index": N, "enabled": true} (push-push toggle)
User-defined modulation buttons
--------------------------------
Up to 10 user-defined modulation buttons (N = 1..10) can be configured via CLI
flags and are advertised to the GUI in "user_mod_labels" / "user_mod_types"
lists inside every "state" dict:
--user_mod_N LABEL Label for user-mod button N (max 4 chars)
--user_mod_type_N TYPE "normal", "text", or "text_input". Default: "normal"
* "normal" behaves like a standard mode button (e.g. AM/USB/...).
* "text" when selected, the GUI splits its AF/audio box in two: the
left side keeps the AF waterfall/spectrum, the right side
shows a read-only text panel with text sent by the server.
* "text_input" same split, but the right-hand panel itself is split into
an upper read-only area (text sent by the server) and a
bottom editable box (max 3 lines, auto-scroll) that sends
its contents to the server when the user presses Enter,
like an RTTY chat session.
Text sent by the GUI for a text/text_input mode button arrives as:
{"cmd": "user_text", "index": N, "text": "..."}
The server replies immediately as usual ({"resp": "ok", "state": {...}}) and
also pushes its own text to the GUI asynchronously as:
{"type": "user_text", "index": N, "text": "..."}
This demo server echoes back anything received on a "text_input" slot
(prefixed so it's clearly distinguishable in the chat panel) and, for any
"text"/"text_input" slot, periodically pushes a simulated status line so the
read-only panel has something to display even with no user input.
Real IQ recordings (--iq_wav)
------------------------------
By default the RF spectrum/waterfall is synthetic (a handful of fake
carriers). Pass --iq_wav PATH to a wav file of IQ samples in SDRplay IQ
wav format (stereo PCM/float, I = left channel, Q = right channel,
optionally with an 'auxi' chunk carrying the recorded centre frequency)
and the server will FFT real samples from that file instead, looping the
file forever so playback never runs out. The file's sample rate becomes
the radio's sample rate, and its recorded centre frequency (if present)
seeds the initial tuned frequency.
Real receive audio (--audio_wav)
---------------------------------
By default the downlink "received audio" sent to the GUI over RTP is just
a fake 440 Hz demo tone. Pass --audio_wav PATH to a normal mono/stereo PCM
(or float) wav file and the server will transmit that file's audio to the
GUI instead, looping it forever (so a short recording becomes an endless
"on air" audio source). Stereo files are downmixed to mono and the audio
is resampled to the RTP audio sample rate (8 kHz) if its native sample
rate differs. This only affects the simulated receive audio; it is sent
whenever the radio is running and PTT is off, same as the demo tone it
replaces.
"""
import argparse
import json
import math
import os
import random
import socket
import struct
import sys
import threading
import time
# numpy is only required when --iq_wav is used (FFT of recorded IQ samples).
# The rest of the server works fine without it.
try:
import numpy as np
except ImportError:
np = None
# ── TOML config support ───────────────────────────────────────────────────────
try:
import tomllib as _tomllib # Python 3.11+
except ImportError:
try:
import tomli as _tomllib # pip install tomli
except ImportError:
_tomllib = None
_tomllib_warning = None
if _tomllib is None:
_tomllib_warning = (
"[config] WARNING: tomllib/tomli not found — using minimal built-in TOML parser.\n"
"[config] Multi-line strings, TOML arrays (e.g. sample_rates = [192000, 250000]),\n"
"[config] and inline tables are NOT supported and will be misread silently.\n"
"[config] Install tomli (pip install tomli) for full TOML support."
)
# ── Debug flag ────────────────────────────────────────────────────────────────
# Set to True by --debug; controls whether verbose log messages are printed.
_DEBUG = False
def dprint(*args, **kwargs):
"""Print only when --debug is active."""
if _DEBUG:
print(*args, **kwargs)
_SERVER_CONFIG_NAME = "cat_server.toml" # transport + device list: [server], [audio], [devices]
_DEVICE_CONFIG_NAME = "cat_device.toml" # GUI behaviour for one device profile
# [server] + [audio] + [devices]. This file controls the TCP CAT socket, the
# UDP/RTP audio channel, and the list of selectable device profiles shown in
# the GUI's Device dialog. [devices] only *lists* device profiles here (label
# + path to their cat_device.toml-like file); the buttons/mods that make up
# each profile live in the referenced file, not in this one.
_SERVER_CONFIG_DEFAULTS = {
"server": {
"host": "0.0.0.0",
"port": 50101,
},
"audio": {
"audio_port": 5004,
"no_audio": False,
},
"devices": {
**{f"label_{n}": "" for n in range(1, 21)},
**{f"config_{n}": "" for n in range(1, 21)},
},
}
# [user_buttons] + [user_mods] + [rf_usr_btns] + [sdr]. Anything that
# modifies the buttons/mods/sample-rates of a single device profile lives
# here, in cat_device.toml (or whatever file --device-config / a
# [devices].config_N entry points to). No [devices] section here -- the
# device *list* lives only in cat_server.toml; a device profile file just
# describes its own buttons and sample rates.
_DEVICE_CONFIG_DEFAULTS = {
"user_buttons": {
**{f"label_{n}": "" for n in range(1, 15)},
**{f"type_{n}": "normal" for n in range(1, 15)},
**{f"list_{n}": "" for n in range(1, 15)},
},
"user_mods": {
**{f"label_{n}": "" for n in range(1, 11)},
**{f"type_{n}": "normal" for n in range(1, 11)},
},
"rf_usr_btns": {
**{f"label_{n}": "" for n in range(1, 12)},
**{f"mode_{n}": "normal" for n in range(1, 12)},
# config_N: JSON-encoded dict of named configure objects shown when the
# button is held for 3 seconds. Empty string or "{}" = no dialog.
# Each key is the label; value is {"type":..., ...}. Types:
# slide → {"type":"slide","range":[min,max]}
# list → {"type":"list","values":[{"key":"Label","val":"value"},…]}
# check → {"type":"check"}
# radio → {"type":"radio","options":["val1","val2",…]}
**{f"config_{n}": "{}" for n in range(1, 12)},
},
"sdr": {
# Sample rate (Hz) applied when this device profile is loaded.
# Must be one of the values listed in sample_rates below.
"sample_rate": 192000,
# Comma-separated list of sample rates (Hz) selectable from the
# GUI's Sample Rate dialog for this device.
"sample_rates": "192000,250000,500000,1000000,2000000",
# Comma-separated list of amateur bands permitted for this device.
# Valid names: 160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m.
# All bands are allowed by default (empty string = all).
"allowed_bands": "160m,80m,60m,40m,30m,20m,17m,15m,12m,10m,6m",
# Comma-separated list of TX power levels (watts) selectable in the GUI.
# Example: "5.0,10.0,25.0,50.0,100.0". Empty = power selection hidden.
"power_levels": "",
},
"antenna": {
# Antenna port labels (up to 10). Empty label = slot unused/hidden.
# label_N: human-readable name shown in the GUI antenna list.
# The GUI returns the 1-based index of the chosen slot to the server.
**{f"label_{n}": "" for n in range(1, 11)},
# allowed_bands_N: comma-separated list of amateur bands allowed
# when antenna N is selected (e.g. "160m,80m,40m,20m,10m"). Empty string
# means the antenna inherits the device-level allowed_bands restriction.
# Valid names: 160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m.
**{f"allowed_bands_{n}": "" for n in range(1, 11)},
},
}
def _parse_simple_toml(text):
"""Minimal TOML parser for simple key=value with [sections].
Used as a fallback by `_load_toml` for both cat_server.toml-style and
cat_device.toml-style files when neither tomllib nor tomli is available.
"""
result = {}
section = result
for raw in text.splitlines():
# Strip comments only when the '#' is outside of a quoted region.
line = raw
in_dquote = False
in_squote = False
for ci, ch in enumerate(raw):
if ch == '"' and not in_squote:
in_dquote = not in_dquote
elif ch == "'" and not in_dquote:
in_squote = not in_squote
elif ch == '#' and not in_dquote and not in_squote:
line = raw[:ci]
break
line = line.strip()
if not line:
continue
if line.startswith('[') and line.endswith(']'):
sec_name = line[1:-1].strip()
# NOTE: This minimal fallback parser does NOT support dotted
# section headers (e.g. [server.tls]) or inline tables.
# The bundled config templates never use them. If tomllib /
# tomli is available it is used instead (see _load_toml).
if '.' in sec_name:
raise ValueError(
f"_parse_simple_toml: nested section [{sec_name}] is "
"not supported by the built-in fallback parser. "
"Install 'tomli' (pip install tomli) for full TOML support."
)
section = result.setdefault(sec_name, {})
continue
if '=' in line:
k, _, v = line.partition('=')
k = k.strip(); v = v.strip()
# Quoted keys: strip surrounding double-quotes so that
# "BW" = "Ancho" yields key 'BW', not '"BW"'.
# This matches TOML spec where quoted keys are equivalent to
# bare keys with the same string value.
if k.startswith('"') and k.endswith('"') and len(k) >= 2:
k = k[1:-1].replace('\\"', '"').replace('\\\\', '\\')
elif k.startswith("'") and k.endswith("'") and len(k) >= 2:
k = k[1:-1] # single-quoted key: raw string
if v.startswith('"""') or v.startswith("'''"):
# Multi-line string — not supported by this fallback parser.
# NOTE: this check must come BEFORE the single-quote-prefix
# checks below, since a triple-quoted value also starts with
# a single '"'/"'" character and would otherwise be silently
# (and incorrectly) parsed as a normal one-line quoted string.
# Raise early so operators see a clear error rather than a
# silently wrong value.
raise ValueError(
f"_parse_simple_toml: multi-line string value for key '{k}' is "
"not supported by the built-in fallback parser. "
"Install 'tomli' (pip install tomli) for full TOML support."
)
elif v.startswith('"') and v.endswith('"'):
section[k] = v[1:-1].replace('\\"', '"').replace('\\\\', '\\')
elif v.startswith("'") and v.endswith("'"):
section[k] = v[1:-1] # single-quoted: raw, no escape processing
elif v == 'true':
section[k] = True
elif v == 'false':
section[k] = False
elif v.startswith('['):
# TOML array — not supported by this fallback parser.
# Raise early so operators see a clear error rather than a
# silently wrong value (e.g. sample_rates parsed as the
# literal string "[192000, 250000]" instead of a list).
raise ValueError(
f"_parse_simple_toml: TOML array value for key '{k}' is "
"not supported by the built-in fallback parser. "
"Use a comma-separated string instead "
"(e.g. sample_rates = \"192000,250000\"), "
"or install 'tomli' (pip install tomli) for full TOML support."
)
else:
try: section[k] = int(v)
except ValueError:
try: section[k] = float(v)
except ValueError: section[k] = v
return result
def _load_toml(path):
"""Return the parsed TOML dict for any config file, or {} on any error.
Used for cat_server.toml, cat_device.toml, and every per-device
cat_device.toml-like file referenced from a [devices] section.
"""
try:
if _tomllib is not None:
with open(path, "rb") as f:
return _tomllib.load(f)
else:
with open(path, "r", encoding="utf-8") as f:
return _parse_simple_toml(f.read())
except Exception as e:
dprint(f"[config] WARNING: could not read {path}: {e}")
return {}
# ── [bandwidth] section defaults and helpers ──────────────────────────────────
# Default filter bandwidths (Hz) for the most common HF amateur modes.
# Operators can add any extra modulation by editing [bandwidth] in cat_device.toml.
_BANDWIDTH_DEFAULTS = {
"AM": [3000, 6000, 9000, 10000],
"FM": [12500, 25000],
"LSB": [2700, 3600],
"USB": [2700, 3600],
"CW": [250, 500, 1000, 2000],
}
def _parse_bandwidth_value(raw):
"""Parse a bandwidth value from TOML into a sorted list of positive Hz ints.
Accepts a comma-separated string (e.g. "250,500,1000") as written in the
fallback-parser-compatible format, or a native TOML array (list/tuple)
produced by tomllib/tomli. Unknown / non-positive tokens are silently
skipped. Returns an empty list when nothing valid is found.
"""
items = raw if isinstance(raw, (list, tuple)) else str(raw).split(",")
result = []
for item in items:
try:
v = int(float(str(item).strip()))
if v > 0:
result.append(v)
except (TypeError, ValueError):
continue
return sorted(set(result))
def _ensure_bandwidth_section(config_path):
"""Ensure the [bandwidth] section in *config_path* (cat_device.toml or any
per-device config file) exists and contains at least the five default
modulation entries (AM, FM, LSB, USB, CW).
If the section is missing it is appended to the file with all defaults.
If the section exists but is missing one or more default modulations, the
missing entries are added. Any operator-added modulations already in the
section are preserved unchanged.
Returns the complete bandwidth dict {mode_name: [Hz, ...]} so the
caller can pass it into RadioState and use it for startup validation.
"""
raw_cfg = _load_toml(config_path)
bw_raw = raw_cfg.get("bandwidth")
bw_raw = bw_raw if isinstance(bw_raw, dict) else {}
# Parse all existing entries
bw_map = {}
for mod, val in bw_raw.items():
parsed = _parse_bandwidth_value(val)
if parsed:
bw_map[mod] = parsed
missing = {k: list(v) for k, v in _BANDWIDTH_DEFAULTS.items()
if k not in bw_map}
if not missing and bw_raw:
return bw_map # File already correct — nothing to do
bw_map.update(missing) # Merge in the missing defaults
# Rewrite the file: strip the old [bandwidth] block (if any) then append
# the fully updated section. We operate directly on the text so only the
# [bandwidth] section is touched; every other section is left byte-for-byte
# identical.
try:
with open(config_path, "r", encoding="utf-8") as fh:
content = fh.read()
# Remove any existing [bandwidth] section
out_lines = []
in_bw = False
for line in content.splitlines():
stripped = line.strip()
if stripped == "[bandwidth]":
in_bw = True
continue
if in_bw and stripped.startswith("[") and stripped.endswith("]"):
in_bw = False
if not in_bw:
out_lines.append(line)
# Build the replacement [bandwidth] section
bw_section = [
"",
"[bandwidth]",
"# Available filter bandwidths (Hz) for each modulation mode.",
"# The GUI bandwidth selector is populated from the entry whose",
"# name matches the currently active modulation mode.",
"# Add an entry here for every modulation label used in",
"# [user_mods] of this file. Values are comma-separated Hz.",
]
# Emit default modulations in canonical order, extras sorted after.
for mod in list(_BANDWIDTH_DEFAULTS.keys()) + sorted(
k for k in bw_map if k not in _BANDWIDTH_DEFAULTS):
if mod in bw_map:
val_str = ",".join(str(v) for v in bw_map[mod])
bw_section.append(f'{mod} = "{val_str}"')
new_content = (
"\n".join(out_lines).rstrip("\n") + "\n"
+ "\n".join(bw_section) + "\n"
)
with open(config_path, "w", encoding="utf-8") as fh:
fh.write(new_content)
if not bw_raw:
dprint(f"[config] Added default [bandwidth] section to {config_path}")
else:
dprint(f"[config] Corrected {config_path} [bandwidth]: added missing "
f"modulation(s): {', '.join(sorted(missing))}")
except Exception as exc:
dprint(f"[config] WARNING: could not update [bandwidth] in "
f"{config_path}: {exc} — using built-in defaults for this run")
return bw_map
class _ConfigSpec:
"""Describes one kind of TOML config file: its sections, the default
value for every key, the order keys/sections are rendered in, and the
comments to emit. The same load/merge/render/ensure machinery below
works for any file described this way -- used here for both
cat_server.toml (transport settings) and cat_device.toml (GUI/device
settings, also reused for every per-device config file)."""
def __init__(self, defaults, key_order, section_order, header, section_comments=None):
self.defaults = defaults
self.key_order = key_order
self.section_order = section_order
self.header = header
self.section_comments = section_comments or {}
_SERVER_CONFIG_KEY_ORDER = {
"server": ["host", "port"],
"audio": ["audio_port", "no_audio"],
"devices": [k for n in range(1, 21) for k in (f"label_{n}", f"config_{n}")],
}
_SERVER_CONFIG_SECTION_ORDER = ["server", "audio", "devices"]
_SERVER_CONFIG_HEADER = (
"# CAT Server configuration\n"
"# [server] + [audio] + [devices] belong in this file. [server]/[audio]\n"
"# are the TCP CAT socket and the UDP/RTP audio channel. [devices] is\n"
"# just the *list* of selectable device profiles (label + path to its\n"
"# cat_device.toml-like file) shown in the GUI's Device dialog -- the\n"
"# buttons/mods that make up each profile live in the referenced file,\n"
"# not here. Anything else that affects GUI behaviour (a single\n"
"# profile's user buttons, user-mod buttons, RF user buttons) lives in\n"
"# the device config instead -- see cat_device.toml.\n"
"# CLI flags override [server]/[audio] values at runtime without\n"
"# modifying this file (there are no CLI flags for [devices]).\n"
"# Use --config PATH to load a file from a non-default location."
)
_SERVER_CONFIG_SECTION_COMMENTS = {
"devices":
'# Up to 20 SDR device profiles. label_N = display name shown in the GUI.\n'
'# config_N = path to a cat_device.toml-like file ([user_buttons]/[user_mods]/\n'
'# [rf_usr_btns] only -- no nested [devices]) loaded when that device is selected.\n'
'# Devices with empty labels are hidden. Fill slots in order: 1, 2, 3…\n'
'#\n'
'# TRANSLATIONS: each device config file MUST have its own separate translations\n'
'# file. The file is named <device_base>_labels_<lang>.toml, derived from that\n'
'# device\'s config path -- NOT from a shared global file. Example:\n'
'# config_1 = "devices/g90.toml" → devices/g90_labels_es.toml\n'
'# config_2 = "devices/ic7300.toml" → devices/ic7300_labels_es.toml\n'
'# One translations file per device; never share a single file across devices.',
}
_DEVICE_CONFIG_KEY_ORDER = {
"user_buttons": [k for n in range(1, 15) for k in (f"label_{n}", f"type_{n}", f"list_{n}")],
"user_mods": [k for n in range(1, 11) for k in (f"label_{n}", f"type_{n}")],
"rf_usr_btns": [k for n in range(1, 12) for k in (f"label_{n}", f"mode_{n}", f"config_{n}")],
"sdr": ["sample_rate", "sample_rates", "allowed_bands", "power_levels"],
"antenna": [f"label_{n}" for n in range(1, 11)] + [f"allowed_bands_{n}" for n in range(1, 11)],
}
_DEVICE_CONFIG_SECTION_ORDER = ["user_buttons", "user_mods", "rf_usr_btns", "sdr", "antenna"]
_DEVICE_CONFIG_HEADER = (
"# CAT Device configuration\n"
"# Describes ONE device profile's GUI buttons: user-defined buttons,\n"
"# user-defined modulation buttons, RF user buttons, and the selectable\n"
"# SDR sample rates. No [devices] section here -- the list of selectable\n"
"# device profiles lives only in cat_server.toml's [devices] section.\n"
"# CLI flags override these values at runtime without modifying this file.\n"
"# Use --device-config PATH to load a file from a non-default location.\n"
"#\n"
"# This file's structure is reused for every device profile: each entry\n"
"# in cat_server.toml's [devices] section (config_N) should point to\n"
"# another file shaped exactly like this one -- a 'cat_device.toml-like\n"
"# file' -- loaded wholesale when that device is selected in the GUI.\n"
"#\n"
"# TRANSLATIONS: each device config file must have its own SEPARATE\n"
"# translations file, named <this_file_base>_labels_<lang>.toml.\n"
"# Example: if this file is 'devices/g90.toml', its Spanish translations\n"
"# live in 'devices/g90_labels_es.toml'. Never share one translations\n"
"# file across multiple device configs -- one file per device."
)
_DEVICE_CONFIG_SECTION_COMMENTS = {
"user_buttons":
'# label: max 7 characters\n'
'# type: "normal" (momentary press), "push" (push-push toggle), or\n'
'# "list" (opens a selection list; choice persists on the button)\n'
'# list_N: comma-separated items shown in the list dialog when type is "list"\n'
'# (max 20 characters per item; unused when type != "list")',
"user_mods":
'# label: max 4 characters; user-defined modulation button labels shown in the\n'
'# mode row of the GUI. Fill slots in order: 1, 2, ..., 10 (no skipping).\n'
'# type: "normal" (acts like a regular mode button), "text" (splits the AF/\n'
'# audio box so a read-only text panel shows text sent by the server),\n'
'# or "text_input" (same split, but the text panel itself is split into\n'
'# an upper read-only area and a bottom 3-line editable input box that\n'
'# sends text to the server on Enter, like an RTTY chat).',
"rf_usr_btns":
'# RF user buttons: shown left of the band buttons in the GUI frequency area.\n'
'# label: max 7 characters; mode: "normal" (momentary) or "push" (toggle).\n'
'# Buttons with empty labels are hidden in the GUI.',
"sdr":
'# sample_rate: SDR hardware sample rate (Hz) applied when this device\n'
'# profile is loaded -- must be one of the values listed in sample_rates.\n'
'# An active --iq_wav recording always overrides this with its own rate.\n'
'# sample_rates: comma-separated list of sample rates (Hz) the operator can\n'
'# choose from the GUI\'s "Sample Rate" dialog for this device.\n'
'# allowed_bands: comma-separated list of amateur bands this device permits\n'
'# (e.g. "160m,80m,40m,20m,10m"). Omit or set to all 11 bands to allow\n'
'# everything. Disallowed bands are greyed out in the GUI; in restrict-band\n'
'# mode, frequencies within disallowed bands are also blocked.\n'
'# power_levels: comma-separated list of TX power levels in watts shown in\n'
'# the GUI Power selector (e.g. "5.0,10.0,25.0,50.0,100.0").\n'
'# Empty or omitted = power selector is hidden in the GUI.',
"antenna":
'# label_N (N=1..10): human-readable label for antenna port N shown\n'
'# in the GUI Antenna selector. Empty = slot unused/hidden. The GUI sends\n'
'# the 1-based index of the chosen antenna back to the server.\n'
'# allowed_bands_N (N=1..10): comma-separated bands allowed when\n'
'# antenna N is selected (e.g. "160m,80m,40m,20m,10m"). Empty string means\n'
'# inherit the device-level allowed_bands restriction. Valid names:\n'
'# 160m 80m 60m 40m 30m 20m 17m 15m 12m 10m 6m.',
}
SERVER_CONFIG_SPEC = _ConfigSpec(
defaults=_SERVER_CONFIG_DEFAULTS,
key_order=_SERVER_CONFIG_KEY_ORDER,
section_order=_SERVER_CONFIG_SECTION_ORDER,
header=_SERVER_CONFIG_HEADER,
section_comments=_SERVER_CONFIG_SECTION_COMMENTS,
)
DEVICE_CONFIG_SPEC = _ConfigSpec(
defaults=_DEVICE_CONFIG_DEFAULTS,
key_order=_DEVICE_CONFIG_KEY_ORDER,
section_order=_DEVICE_CONFIG_SECTION_ORDER,
header=_DEVICE_CONFIG_HEADER,
section_comments=_DEVICE_CONFIG_SECTION_COMMENTS,
)
def _fmt_toml_value(v):
"""Render a Python value back into TOML literal syntax.
Strings that contain double-quotes but no single-quotes are written as
TOML single-quoted (literal) strings so that embedded JSON – which uses
double-quotes extensively – is stored without any escape sequences.
This keeps the file human-readable and avoids the \\"-escape sequences
that the built-in fallback _parse_simple_toml would have to unescape.
All other strings use the standard double-quoted form.
"""
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, str):
if '"' in v and "'" not in v:
# Safe to use single-quoted (literal) TOML string: no escaping needed.
return "'" + v + "'"
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
return str(v)
def _parse_sample_rates(raw):
"""Parse a device config's 'sdr.sample_rates' value into a sorted list
of unique positive ints. Accepts either the comma-separated string
stored in the TOML file (e.g. "192000,250000,500000") or an existing
list/tuple of values. Any unparsable entries are silently skipped; if
nothing valid is found, falls back to a single 192000 Hz entry so the
GUI's Sample Rate dialog always has at least one choice to show.
"""
items = raw if isinstance(raw, (list, tuple)) else str(raw).split(",")
rates = set()
for item in items:
try:
v = int(float(str(item).strip()))
if v > 0:
rates.add(v)
except (TypeError, ValueError):
continue
return sorted(rates) if rates else [192000]
def _merge_config_with_defaults(cfg, spec):
"""Fill in any section/key missing from cfg using spec.defaults.
Returns (merged, added): merged is a complete config dict (every default
section/key present, existing values always win) and added is an ordered
list of "section.key" strings that were absent from cfg and had to be
filled in with their default value.
"""
merged = {}
added = []
for sec, sec_defaults in spec.defaults.items():
_cfg_sec_raw = cfg.get(sec, {})
cfg_sec = _cfg_sec_raw if isinstance(_cfg_sec_raw, dict) else {}
merged_sec = {}
for key, default_val in sec_defaults.items():
if key in cfg_sec:
merged_sec[key] = cfg_sec[key]
else:
merged_sec[key] = default_val
added.append(f"{sec}.{key}")
merged[sec] = merged_sec
return merged, added
def _render_config(cfg, spec, extra_raw_sections=None):
"""Render a complete, well-formed TOML document from a fully-merged
config dict, per the section/key order and comments in `spec`.
extra_raw_sections: optional dict {section_name: {key: raw_value}}
for sections not part of the spec (e.g. [bandwidth] in cat_device.toml) that should be
appended verbatim after the spec-managed sections so they are preserved
across rewrites triggered by self-correction logic.
"""
lines = [spec.header]
for sec in spec.section_order:
lines.append("")
lines.append(f"[{sec}]")
comment = spec.section_comments.get(sec)
if comment:
lines.append(comment)
sec_vals = cfg.get(sec, {})
for key in spec.key_order[sec]:
val = sec_vals.get(key, spec.defaults[sec][key])
lines.append(f"{key} = {_fmt_toml_value(val)}")
if extra_raw_sections:
for sec_name, entries in extra_raw_sections.items():
if not isinstance(entries, dict):
continue
lines.append("")
lines.append(f"[{sec_name}]")
for k, v in entries.items():
if isinstance(v, (list, tuple)):
# Serialize list as a comma-separated string — compatible
# with the fallback parser which does not support TOML arrays.
lines.append(f'{k} = "{",".join(str(x) for x in v)}"')
else:
lines.append(f"{k} = {_fmt_toml_value(v)}")
return "\n".join(lines) + "\n"
def _ensure_config(path, spec, kind="config"):
"""Create `path` with defaults (per `spec`) if it does not exist, then
load and return it. `kind` is only used for log messages, e.g. "server
config" or "device config".
If the file already exists but is missing a parameter known to this
version (i.e. one with a corresponding default in `spec`), the file is
corrected in place: the missing parameter is added at its default value
and rewritten to disk, so the config keeps itself up to date as new
options are introduced in later versions.
If the file exists and contains section(s) that aren't part of `spec`
at all, a note is printed -- this is the common symptom of an old,
pre-split single-file config that still has GUI-behaviour sections
mixed into the server config (or vice versa) and needs those sections
moved to the other file by hand.
Sections that are intentionally managed outside the spec machinery
(currently [bandwidth] in cat_device.toml) are silently preserved in any
rewrite rather than being discarded or warned about.
"""
# Sections managed by separate code outside _ConfigSpec, preserved on rewrite.
_SILENTLY_PRESERVED = {"bandwidth"}
if not os.path.exists(path):
dprint(f"[config] {path} not found — using built-in defaults")
return dict(spec.defaults)
_cfg = _load_toml(path)
# Collect sections outside the spec to preserve them verbatim on any rewrite.
_extra_secs = {
sec: val for sec, val in _cfg.items()
if sec not in spec.section_order and isinstance(val, dict)
}
# Only warn about truly unexpected sections (not the ones we intentionally manage).
_warn_secs = [sec for sec in _extra_secs if sec not in _SILENTLY_PRESERVED]
if _warn_secs:
dprint(f"[config] NOTE: {path} has section(s) not used by this file "
f"({kind}): {', '.join(_warn_secs)} — they are ignored here. "
f"[server]/[audio]/[devices] belong in cat_server.toml; "
f"[user_buttons]/[user_mods]/[rf_usr_btns]/[sdr]/[antenna] belong in "
f"cat_device.toml (or your --device-config / [devices].config_N "
f"file).")
merged, added = _merge_config_with_defaults(_cfg, spec)
if added:
try:
with open(path, "w", encoding="utf-8") as f:
# Pass extra_raw_sections so sections like [bandwidth] are not
# silently discarded when the device config is self-corrected.
f.write(_render_config(merged, spec, extra_raw_sections=_extra_secs))
dprint(f"[config] Corrected {path}: added missing parameter(s) with "
f"default value(s) — {', '.join(added)}")
except Exception as e:
dprint(f"[config] WARNING: {path} is missing parameter(s) "
f"({', '.join(added)}) but could not be corrected: {e} — "
f"using built-in defaults for this run")
return merged
NUM_BINS = 600 # RF spectrum / waterfall bins
AF_BINS = 256 # AF spectrum / waterfall bins
AF_RANGE = 3000.0 # Hz shown on the AF display
UPDATE_HZ = 10.0 # data pushes per second
NUM_USER_BUTTONS = 14 # number of user-defined buttons (N = 1..14)
NUM_USER_MODS = 10 # number of user-defined modulation buttons (N = 1..10)
NUM_RF_USR_BTNS = 11 # number of RF user buttons (N = 1..11, left of band buttons)
IQ_FFT_SIZE = 4096 # FFT size used to turn --iq_wav samples into a spectrum
# ── Frequency memories ────────────────────────────────────────────────────────
# Each device keeps its own independent set of memories, one list of 20 slots
# per frequency row ("LO A", "LO B", "Tune"). Persisted to a small JSON file
# next to that device's config file so memories survive restarts and stay
# tied to the device profile, not the session.
MEMORY_POSITIONS = ("LO A", "LO B", "Tune")
NUM_MEMORY_SLOTS = 20 # memory slots per position (M1..M20)
MEMORY_LABEL_MAXLEN = 10 # max characters in a memory label
# ── RTP / UDP audio ──────────────────────────────────────────────────────────
AUDIO_SAMPLE_RATE = 8000 # Hz
AUDIO_FRAME_MS = 20 # milliseconds per RTP packet
AUDIO_FRAME_SAMPS = int(AUDIO_SAMPLE_RATE * AUDIO_FRAME_MS / 1000) # 160
AUDIO_RTP_TYPE = 0 # PCM μ-law payload type (RTP PT 0 = PCMU)
AUDIO_UDP_PORT = 5004 # default; overridable via --audio-port
# Simple sine-wave beep sent while running and PTT is off (receive side demo)
_AUDIO_TONE_HZ = 440 # Hz of the demo tone the server sends
NOISE_FLOOR_DBM = -120.0
DB_S9 = -73.0 # dBm that corresponds to "S9" on the meter
DB_PER_S_UNIT = 6.0 # 6 dB per S-unit below S9
# ── Amateur band edges (Hz) used to enforce [sdr].allowed_bands /
# [antenna].allowed_bands_N ("restrict-band mode") against tuning commands.
# These are simplified, generic worldwide HF band edges -- sufficient for
# this demo's tuning-restriction feature, but NOT an authoritative
# per-country band plan (60m in particular is heavily channelized in most
# administrations). Treat this as a coarse guard rail, not a legal reference.
_BAND_EDGES = {
"160m": (1_800_000, 2_000_000),
"80m": (3_500_000, 4_000_000),
"60m": (5_250_000, 5_450_000),
"40m": (7_000_000, 7_300_000),
"30m": (10_100_000, 10_150_000),
"20m": (14_000_000, 14_350_000),
"17m": (18_068_000, 18_168_000),
"15m": (21_000_000, 21_450_000),
"12m": (24_890_000, 24_990_000),
"10m": (28_000_000, 29_700_000),
"6m": (50_000_000, 54_000_000),
}
def _freq_to_band(hz):
"""Return the amateur band name (e.g. "40m") containing *hz*, or None
if *hz* doesn't fall inside any known band edge in _BAND_EDGES."""
for band, (lo, hi) in _BAND_EDGES.items():
if lo <= hz <= hi:
return band
return None
def dbm_to_s_text(dbm):
"""Convert a dBm reading into an 'S' string like the CAT GUI meter."""
if dbm >= DB_S9:
over = dbm - DB_S9
if over < 0.5:
return "S9"
return f"S9 +{over:0.0f}dB"
s = 9 - (DB_S9 - dbm) / DB_PER_S_UNIT
s = max(0.0, s)
return f"S{s:0.0f}"
# ── RTP helpers ───────────────────────────────────────────────────────────────
def _rtp_pack(payload: bytes, seq: int, ts: int, ssrc: int = 0x1234ABCD) -> bytes:
"""Pack a minimal 12-byte RTP header + payload."""
# V=2, P=0, X=0, CC=0, M=0, PT=AUDIO_RTP_TYPE
byte0 = 0x80
byte1 = AUDIO_RTP_TYPE & 0x7F
return struct.pack("!BBHII", byte0, byte1, seq & 0xFFFF, ts, ssrc) + payload
def _rtp_unpack(data: bytes):
"""Return (payload, seq, ts) from a raw RTP datagram, or None on error."""
if len(data) < 12:
return None
hdr = struct.unpack("!BBHII", data[:12])
if (hdr[0] >> 6) != 2: # RTP version must be 2
return None
seq = hdr[2]
ts = hdr[3]
return data[12:], seq, ts
def _linear16_to_ulaw(samples: bytes) -> bytes:
"""Convert raw 16-bit little-endian PCM to 8-bit μ-law bytes."""
if np is not None:
# Vectorised fast-path: ~20× faster than the pure-Python loop below.
s = np.frombuffer(samples, dtype="<i2").astype(np.int32)
sign = (s < 0).astype(np.uint8) * 0x80
s = np.abs(s).clip(0, 32767) + 33
exp = np.floor(np.log2(s)).clip(5, 12).astype(np.uint8) - 5
mantissa = ((s >> (exp.astype(np.int32) + 1)) & 0x0F).astype(np.uint8)
return (~(sign | (exp << 4) | mantissa) & 0xFF).astype(np.uint8).tobytes()
# Pure-Python fallback (used only when numpy is unavailable).
out = bytearray(len(samples) // 2)
for i in range(len(out)):
s = struct.unpack_from("<h", samples, i * 2)[0]
# μ-law encoding
sign = 0 if s >= 0 else 0x80
if s < 0:
s = -s
s = min(s, 32767)
s += 33
exp = 0 # defensive default; loop below will override for all valid s
for e in range(7, -1, -1):
if s >= (1 << (e + 5)):
exp = e
break
mantissa = (s >> (exp + 1)) & 0x0F
ulaw = ~(sign | (exp << 4) | mantissa) & 0xFF
out[i] = ulaw
return bytes(out)
def _ulaw_to_linear16(ulaw_bytes: bytes) -> bytes:
"""Convert 8-bit μ-law bytes to raw 16-bit little-endian PCM."""
out = bytearray(len(ulaw_bytes) * 2)
for i, b in enumerate(ulaw_bytes):
b = ~b & 0xFF
sign = b & 0x80
exp = (b >> 4) & 0x07
mantissa = b & 0x0F
s = ((mantissa << 1) | 0x21) << exp
s -= 33
if sign:
s = -s
s = max(-32768, min(32767, s))
struct.pack_into("<h", out, i * 2, s)
return bytes(out)
def _gen_sine_frame(freq: float, sample_rate: int, frame_samps: int,
phase_ref: list) -> bytes: