14 Commits
Author SHA1 Message Date
James C. Ahlstrom c2085a8160 Remove wheel tester. 2026-10-27 17:05:25 -04:00
James C. Ahlstrom 2b6b6fa9ea Merge branch 'master' of github.com:jimahlstrom/quisk
Merge mouse wheel logic.
2026-10-27 16:58:05 -04:00
James C. Ahlstrom 36210d4e4c Request by Jaroslav, OK2JRQ: alternate docs directory. 2026-10-27 16:56:09 -04:00
jimahlstrom eb9434c52a Merge pull request #4 from prof7bit/fix-wheel-steps
Fix symmetric mouse wheel tuning steps. Thanks to Bernd, DD4UG.
2026-10-27 16:49:51 -04:00
Bernd db928b6fcb Fix symmetric mouse wheel tuning steps 2026-10-24 13:45:22 +02:00
jimahlstrom a90f8b80bd Merge pull request #3 from argusSecurityBot/fix-bounds-checks-and-leak
Fix out-of-bounds accesses and a leak in quisk.c and afedrinet_io.c
2026-10-22 16:13:49 -04:00
argusSecurityBot faaeda54e5 Fix out-of-bounds accesses and a leak in quisk.c and afedrinet_io.c
- afedrinet_io.c open_rx_udp: use snprintf so a long ip string cannot
  overflow the 128-byte stack buffer.
- quisk.c set_multirx_mode, set_multirx_freq: reject a negative index.
- quisk.c set_multirx_play_channel: treat a negative channel like an
  out-of-range one.
- quisk.c measure_freq: keep ipeak inside [1, fft_size - 2] so that
  fft_average[ipeak - 1] and fft_average[ipeak + 1] stay in bounds when
  no peak is found.
- quisk.c record_app: free the previous multirx_fft_next_samples buffer
  before allocating a new one, like fft_window and current_graph.
2026-10-20 16:46:24 +00:00
James C. Ahlstrom c2304b23ee Minor correction to UT3MK radio 2026-10-19 13:43:22 -04:00
jimahlstrom 09154658ae Merge pull request #2 from zxalexis/master
Added UT3MK SDR radios
2026-10-19 13:22:35 -04:00
James C. Ahlstrom 20bd313041 Correction to makefile 2026-10-19 13:00:09 -04:00
zx d39722b170 Added UT3MK SDR radios 2026-10-18 22:02:37 +03:00
James C. Ahlstrom c97ffa155e Quisk version 4.2.55 2026-10-12 13:37:47 -04:00
jimahlstrom 745882a506 Add README for Quisk Software Defined Radio
Added introductory information about Quisk software defined radio, including its features and support links.
2026-09-27 16:19:11 -04:00
James C. Ahlstrom 674bbad1ab fix non-ascii char in setup.py 2026-08-20 14:08:21 -04:00
19 changed files with 775 additions and 46 deletions
+6
View File
@@ -1,3 +1,9 @@
Quisk Version 4.2.55 October 2026
==================================
Karl Heinz, K5KHK, provided a patche to fix the AC2YD remote control head. He provided another patch
to fix Hamlib control when using WSJT-X improved. He added a mic jitter buffer to the remote control
head so it works over WiFi. Thanks Karl!
Quisk Version 4.2.54 August 2026
=================================
Quisk can now accept audio samples for Tx from TCI at rates 48000, 24000, 12000 and 8000. Please test.
+12
View File
@@ -0,0 +1,12 @@
# Quisk Software Defined Radio
Quisk is a software defined radio transceiver that runs on a PC or on a single board computer such as a Raspberry Pi.
It is written in Python and C.
It works with various kinds of radio hardware such as SoftRock, Hermes Lite 2,
the SDR-IQ by RfSpace, and with network UDP samples.
Quisk is easily extensible by writing your own hardware file in Python.
I wrote Quisk back in 2008, and I have been maintaining it ever since.
For questions and support, please visit the group https://groups.io/g/n2adr-sdr.
For more information see https://james.ahlstrom.name/quisk.
+1 -1
View File
@@ -1,2 +1,2 @@
#Quisk version 4.2.54
#Quisk version 4.2.55
from .quisk import main
+101 -8
View File
@@ -53,6 +53,41 @@ static int control_head_data_width; // app.data_width of control head for gr
static int packets_sent;
static int packets_recd;
///// The jitter buffer is thanks to Karl Heinz - K5KHK
// Jitter buffer for the microphone samples received from the control head. The network can hold
// packets for 100 msec or more (Wi-Fi power saving and scanning) and then deliver them in a burst,
// but the Hermes Tx buffer only has 50 msec of slack. Without a buffer here, each late burst is an
// underflow (a gap on the air) followed by an overflow (discarded samples). So buffer mic_jb_target
// frames before playing, and then release them at a steady MIC_JB_RATE.
// The buffer adds its delay to the transmit audio, so it is off unless the configuration item
// remote_radio_mic_jitter_ms is set. When it is off, samples are passed on as they arrive.
#define MIC_JB_RATE 8000 // frames per second sent by the control head
#define MIC_JB_SIZE 8000 // ring buffer size in stereo frames
#define MIC_JB_MAX_MS 500 // largest configurable delay; MIC_JB_SIZE must hold this plus MIC_JB_EXTRA
#define MIC_JB_EXTRA 2000 // trim back to mic_jb_target above mic_jb_target + this (clock drift): 250 msec
#define MIC_JB_IDLE 0.5 // seconds without packets that end a transmission
static int16_t mic_jb[MIC_JB_SIZE * 2];
static int mic_jb_target; // frames to buffer before playing, from remote_radio_mic_jitter_ms; zero for off
static int mic_jb_read, mic_jb_count, mic_jb_playing, mic_jb_starved;
static int mic_jb_underruns, mic_jb_overruns;
static double mic_jb_time, mic_jb_frac, mic_jb_last_packet;
static void mic_jb_reset(void)
{
int msec;
msec = QuiskGetConfigInt("remote_radio_mic_jitter_ms", 0);
if (msec < 0)
msec = 0;
else if (msec > MIC_JB_MAX_MS)
msec = MIC_JB_MAX_MS;
mic_jb_target = msec * MIC_JB_RATE / 1000;
mic_jb_read = mic_jb_count = mic_jb_playing = mic_jb_starved = 0;
mic_jb_underruns = mic_jb_overruns = 0;
mic_jb_frac = 0;
}
// Receive stereo 16-bit pcm radio speaker sound on the control head via UDP
int read_remote_radio_sound_socket(complex double * cSamples)
{
@@ -117,9 +152,9 @@ int read_remote_radio_sound_socket(complex double * cSamples)
// Receive stereo 16-bit pcm microphone samples at the remote radio via UDP
int read_remote_mic_sound_socket(complex double * cSamples)
{
int i, bytes, nInt16, nSamples;
int i, j, bytes, nInt16, nSamples, nFrames;
int16_t buf[UDP_SEND_INT16];
double samp_r, samp_l;
double now, due;
struct timeval tm_wait;
fd_set fds;
static struct quisk_cHB45Filter HalfBand;
@@ -134,8 +169,8 @@ int read_remote_mic_sound_socket(complex double * cSamples)
quisk_filt_cInit(&cFiltInterp3, quiskAudio24p3Coefs, sizeof(quiskAudio24p3Coefs)/sizeof(double));
}
// read all available packets, one per loop
nSamples = 0;
// read all available packets into the jitter buffer, one per loop
now = QuiskTimeSec();
while (1) {
tm_wait.tv_sec = 0;
tm_wait.tv_usec = 0;
@@ -152,15 +187,71 @@ int read_remote_mic_sound_socket(complex double * cSamples)
break;
}
if (bytes > 0) {
if (mic_jb_starved) { // the buffer ran dry; count it unless the transmission had ended
mic_jb_starved = 0;
if (now - mic_jb_last_packet < MIC_JB_IDLE)
mic_jb_underruns++;
}
mic_jb_last_packet = now;
nInt16 = bytes / 2;
for (i = 0; i < nInt16; i += 2) {
samp_r = buf[i];
samp_l = buf[i + 1];
cSamples[nSamples++] = (samp_r + I * samp_l) / CLIP16 * CLIP32;
for (i = 0; i + 1 < nInt16; i += 2) {
if (mic_jb_count >= MIC_JB_SIZE) { // full: drop the oldest frame
mic_jb_read = (mic_jb_read + 1) % MIC_JB_SIZE;
mic_jb_count--;
}
j = (mic_jb_read + mic_jb_count) % MIC_JB_SIZE * 2;
mic_jb[j] = buf[i];
mic_jb[j + 1] = buf[i + 1];
mic_jb_count++;
}
}
} // while(1)
// release the frames that are due since the last call
nSamples = 0;
if (mic_jb_target == 0) { // the jitter buffer is off: pass on everything received
nFrames = mic_jb_count;
}
else if ( ! mic_jb_playing) {
nFrames = 0;
if (mic_jb_count >= mic_jb_target) {
mic_jb_playing = 1;
mic_jb_time = now;
mic_jb_frac = 0;
}
}
else {
due = (now - mic_jb_time) * MIC_JB_RATE + mic_jb_frac;
if (due < 0) // the clock was set back
due = 0;
else if (due > MIC_JB_RATE / 2) // limit to 0.5 sec, well within SAMP_BUFFER_SIZE after interpolation
due = MIC_JB_RATE / 2;
mic_jb_time = now;
nFrames = (int)due;
mic_jb_frac = due - nFrames;
if (nFrames > mic_jb_count) { // ran dry: play what is left, then buffer up again
nFrames = mic_jb_count;
mic_jb_playing = 0;
mic_jb_starved = 1;
}
}
for (i = 0; i < nFrames; i++) {
j = mic_jb_read * 2;
cSamples[nSamples++] = ((double)mic_jb[j] + I * (double)mic_jb[j + 1]) / CLIP16 * CLIP32;
mic_jb_read = (mic_jb_read + 1) % MIC_JB_SIZE;
}
mic_jb_count -= nFrames;
if (mic_jb_target && mic_jb_count > mic_jb_target + MIC_JB_EXTRA) { // the control head's clock is faster than ours
i = mic_jb_count - mic_jb_target;
mic_jb_read = (mic_jb_read + i) % MIC_JB_SIZE;
mic_jb_count -= i;
mic_jb_overruns++;
}
if ((mic_jb_underruns || mic_jb_overruns) && now - mic_jb_last_packet > MIC_JB_IDLE) {
QuiskPrintf("Remote mic jitter buffer: %d underruns, %d overruns\n", mic_jb_underruns, mic_jb_overruns);
mic_jb_underruns = mic_jb_overruns = 0;
}
nSamples = quisk_cInterpolate(cSamples, nSamples, &cFiltInterp3, 3);
nSamples = quisk_cInterp2HB45(cSamples, nSamples, &HalfBand);
@@ -716,6 +807,7 @@ PyObject * quisk_start_remote_radio_remote_sound(PyObject * self, PyObject * arg
packets_sent = 0;
packets_recd = 0;
mic_jb_reset();
return Py_None;
}
@@ -742,6 +834,7 @@ PyObject * quisk_stop_remote_radio_remote_sound(PyObject * self, PyObject * args
control_head_graph_socket_started = 0;
remote_radio_graph_socket_started = 0;
control_head_data_width = 0;
mic_jb_reset();
QuiskPrintf("total packets sent = %i, recd = %i\n", packets_sent, packets_recd);
+22 -3
View File
@@ -190,10 +190,20 @@ class Remot: # Remote comtrol base class
return
try: # Read any data from the socket
text = self.remote_ctl_connection.recv(1024)
except:
#traceback.print_exc()
# Thanks to Karl Heinz - K5KHK
except (BlockingIOError, InterruptedError, socket.timeout): # No data available now
return
except OSError: # For example ConnectionResetError: the Control Head is gone
print('Closing Remote Control connection: recv() failed')
self.RemoteCtlClose(False)
return
else: # We got some characters
if not text: # recv() returns nothing only when the Control Head has closed the connection
# Close now rather than waiting for the heartbeat timeout: a Control Head that
# crashed or lost its network may have left the transmitter keyed.
print('Closing Remote Control connection: closed by Control Head')
self.RemoteCtlClose(False)
return
if not isinstance(text, str):
text = text.decode('utf-8')
self.received += text
@@ -375,13 +385,22 @@ class Remot: # Remote comtrol base class
self.cw_next_keydown = None
def StopTransmit(self):
# TODO: Add code for modes other than CW
# PTT code added thanks to Karl Heinz - K5KHK
# Called whenever the remote session ends (RemoteCtlClose). Release everything the
# Control Head may have keyed: the CW key and, for voice and digital modes, PTT.
# Without the PTT release, a Control Head that disappears while transmitting (a crash,
# a lost network, a dead battery) leaves the transmitter keyed until someone unkeys it
# at the radio.
while len(self.cw_event_queue):
self.cw_event_queue.popleft()
self.cw_next_event_ts = None
self.cw_next_keydown = None
self.cw_key_down = 0
QS.set_remote_cwkey(0)
ptt = getattr(self.app, 'pttButton', None)
if ptt and ptt.GetValue():
print('Remote Control connection closed while transmitting: releasing PTT')
ptt.SetValue(0, True) # Same as releasing PTT at the radio; runs OnButtonPTT
def ProcessRadioBtn(self, command, cmd_text):
# Large and Small format screens send different button events for radio buttons.
+2 -2
View File
@@ -128,10 +128,10 @@ static PyObject * open_rx_udp(const char * ip, int port)
shutdown(rx_udp_socket, QUISK_SHUT_BOTH);
close(rx_udp_socket);
rx_udp_socket = INVALID_SOCKET;
sprintf(buf, "Failed to connect to UDP %s port %u", ip, port);
snprintf(buf, sizeof(buf), "Failed to connect to UDP %s port %u", ip, port);
}
else {
sprintf(buf, "Capture from UDP %s port %u", ip, port);
snprintf(buf, sizeof(buf), "Capture from UDP %s port %u", ip, port);
#if DEBUG_IO
if (getsockopt(rx_udp_socket, SOL_SOCKET, SO_RCVBUF, (char *)&intbuf, &bufsize) == 0)
{
+2 -12
View File
@@ -727,17 +727,6 @@ class Configuration:
count -= 1
return count
class xxConfigHelp(wx.html.HtmlWindow): # The "Help with Radios" first-level page
"""Create the help screen for the configuration tabs."""
def __init__(self, parent):
wx.html.HtmlWindow.__init__(self, parent, -1, size=(win_width, 100))
if "gtk2" in wx.PlatformInfo:
self.SetStandardFonts()
self.SetFonts("", "", [10, 12, 14, 16, 18, 20, 22])
self.SetBackgroundColour(parent.bg_color)
# read in text from file help_conf.html in the directory of this module
self.LoadFile('help_conf.html')
class QPowerMeterCalibration(wx.Frame):
"""Create a window to enter the power output and corresponding ADC value AIN1/2"""
def __init__(self, parent, local_names):
@@ -2302,7 +2291,8 @@ class ControlMixin:
if name in ('hot_key_ptt_toggle', 'hot_key_ptt_if_hidden', 'keyupDelay', 'cwTone', 'pulse_audio_verbose_output',
'start_cw_delay', 'start_ssb_delay', 'maximum_tx_secs', 'quisk_serial_cts', 'quisk_serial_dsr',
'hot_key_ptt1', 'hot_key_ptt2', 'midi_ptt_toggle', 'TxRxSilenceMsec',
'fixed_tune_offset', 'calibrate_temp_20', 'calibrate_temp_40', 'calibrate_current_0', 'calibrate_current_1'):
'fixed_tune_offset', 'calibrate_temp_20', 'calibrate_temp_40', 'calibrate_current_0', 'calibrate_current_1',
'remote_radio_mic_jitter_ms'):
setattr(conf, name, x)
application.ImmediateChange(name)
elif name[0:4] in ('lin_', 'win_'):
+1
View File
@@ -307,6 +307,7 @@ These are part of your operating system. You only install them once unless it ch
</p>
<br>
<pre>
sudo apt-get install gcc
sudo apt-get install libfftw3-dev
sudo apt-get install libasound2-dev
sudo apt-get install portaudio19-dev
+1 -1
View File
@@ -56,7 +56,7 @@ $(OBJDIR):
@mkdir -p $(OBJDIR)
# Link the shared library object
$(TARGET): $(OBJS)
$(TARGET): $(OBJS) ac2yd/remote.o
$(CC) -o $@ $(OBJS) ac2yd/remote.o $(LDFLAGS)
# Define the location for dependency files
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "quisk"
version = "4.2.54"
version = "4.2.55"
requires-python = ">=3.13"
description = 'QUISK is a Software Defined Radio (SDR) transceiver that can control various radio hardware.'
license = "GPL-2.0-or-later"
+9 -3
View File
@@ -4830,7 +4830,7 @@ static PyObject * set_multirx_mode(PyObject * self, PyObject * args)
if (!PyArg_ParseTuple (args, "ii", &index, &mode))
return NULL;
if (index < QUISK_MAX_SUB_RECEIVERS)
if (index >= 0 && index < QUISK_MAX_SUB_RECEIVERS)
multirx_mode[index] = mode;
Py_INCREF (Py_None);
return Py_None;
@@ -4842,7 +4842,7 @@ static PyObject * set_multirx_freq(PyObject * self, PyObject * args)
if (!PyArg_ParseTuple (args, "ii", &index, &freq))
return NULL;
if (index < QUISK_MAX_SUB_RECEIVERS)
if (index >= 0 && index < QUISK_MAX_SUB_RECEIVERS)
multirx_freq[index] = freq;
Py_INCREF (Py_None);
return Py_None;
@@ -4860,7 +4860,7 @@ static PyObject * set_multirx_play_channel(PyObject * self, PyObject * args)
{
if (!PyArg_ParseTuple (args, "i", &multirx_play_channel))
return NULL;
if (multirx_play_channel >= QUISK_MAX_SUB_RECEIVERS)
if (multirx_play_channel < 0 || multirx_play_channel >= QUISK_MAX_SUB_RECEIVERS)
multirx_play_channel = -1;
Py_INCREF (Py_None);
return Py_None;
@@ -5638,6 +5638,10 @@ static void measure_freq(complex double * cSamples, int nSamples, int srate)
ipeak = i;
}
}
if (ipeak < 1)
ipeak = 1;
else if (ipeak > fft_size - 2)
ipeak = fft_size - 2;
c3 = 1.36 * (fft_average[ipeak+1] - fft_average[ipeak - 1]) / (fft_average[ipeak-1] + fft_average[ipeak] + fft_average[ipeak+1]);
freq = srate * (2 * (ipeak + c3) - fft_size) / 2 / fft_size;
freq += rx_tune_freq;
@@ -6006,6 +6010,8 @@ static PyObject * record_app(PyObject * self, PyObject * args)
}
// Initialize plan for multirx FFT
multirx_fft_width = multirx_data_width * MULTIRX_FFT_MULT; // Use larger FFT than graph size
if (multirx_fft_next_samples)
free(multirx_fft_next_samples);
multirx_fft_next_samples = (fftw_complex *)malloc(multirx_fft_width * sizeof(fftw_complex));
multirx_fft_next_plan = quisk_create_or_cache_fftw_plan_dft_1d(multirx_fft_width, multirx_fft_next_samples, multirx_fft_next_samples, FFTW_FORWARD, FFTW_MEASURE);
if (current_graph)
+33 -10
View File
@@ -39,6 +39,7 @@ from filters import Filters
import dxcluster
import configure
import quisk_conf_defaults as conf
import quisk_utils
import quisk_wdsp
for name in configure.name2format: # Add defaults. These may be overwritten by a user config file.
@@ -742,6 +743,7 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532
self.vfo = "Main"
self.split_mode = 0
self.split_vfo = 'VFO'
self.lock_mode = 0
h = self.Handlers = {}
h[''] = self.ErrProtocol
h['dump_state'] = self.DumpState
@@ -765,6 +767,8 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532
h['set_split_freq'] = self.SetSplitFreq
h['get_split_vfo'] = self.GetSplitVfo
h['set_split_vfo'] = self.SetSplitVfo
h['get_lock_mode'] = self.GetLockMode
h['set_lock_mode'] = self.SetLockMode
self.MakeDumpState()
def MakeDumpState(self):
dump_state = []
@@ -1009,7 +1013,8 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532
self.ErrParam()
else:
freq = int(freq + 0.5)
self.app.ChangeRxTxFrequency(freq, None)
# Like a real radio, keep the mode. Hamlib caches the mode, so the client would not notice a mode change.
self.app.ChangeRxTxFrequency(freq, None, keep_mode=True)
def GetSplitFreq(self): # The Tx Frequency
self.Reply('TX Frequency', self.app.txFreq + self.app.VFO, 0)
def SetSplitFreq(self): # The Tx Frequency
@@ -1022,7 +1027,7 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532
else:
freq = int(freq + 0.5)
# This always controls the Tx frequency
self.app.ChangeRxTxFrequency(None, freq)
self.app.ChangeRxTxFrequency(None, freq, keep_mode=True)
def GetSplitVfo(self):
self.Reply('Split', self.split_mode, 'TX VFO', self.split_vfo, 0)
def SetSplitVfo(self):
@@ -1107,6 +1112,22 @@ class HamlibHandlerRig2: # Test with telnet localhost 4532
Lab = label
diff = df
self.app.filterButns.SetLabel(Lab, True)
# The code for the lock_mode and keep_mode was supplied by Karl Heinz - K5KHK. Thanks Karl!
def GetLockMode(self):
# Hamlib netrigctl always reads two lines, so an error reply "RPRT -4" makes it wait for its 10 second timeout.
# Reply like rigctld with the lock value and then "RPRT 0".
if self.extended:
self.Reply('Locked', self.lock_mode, 0)
else:
self.Send("%d\nRPRT 0\n" % self.lock_mode)
def SetLockMode(self):
lock = self.GetParamNumber()
try:
self.lock_mode = int(lock)
except:
self.ErrParam()
else:
self.Reply(0)
def GetVfo(self):
self.Reply('VFO', self.vfo, 0)
def SetVfo(self):
@@ -2564,7 +2585,7 @@ class GraphScreen(wx.Window):
else:
self.mouse_is_rx = False
if self.mouse_is_rx:
freq = application.rxFreq + self.VFO + wm * event.GetWheelRotation() // event.GetWheelDelta()
freq = application.rxFreq + self.VFO + quisk_utils.wheel_step(event.GetWheelRotation(), event.GetWheelDelta(), wm)
if conf.freq_spacing:
freq = self.FreqRound(freq, 0)
elif freq >= 0:
@@ -2574,7 +2595,7 @@ class GraphScreen(wx.Window):
tune = freq - self.VFO
self.ChangeHwFrequency(self.txFreq, self.VFO, 'MouseWheel', event=event, rx_freq=tune)
else:
freq = self.txFreq + self.VFO + wm * event.GetWheelRotation() // event.GetWheelDelta()
freq = self.txFreq + self.VFO + quisk_utils.wheel_step(event.GetWheelRotation(), event.GetWheelDelta(), wm)
if conf.freq_spacing:
freq = self.FreqRound(freq, 0)
elif freq >= 0:
@@ -3642,8 +3663,8 @@ class HelpScreen(wx.html.HtmlWindow):
if "gtk2" in wx.PlatformInfo:
self.SetStandardFonts()
self.SetFonts("", "", [10, 12, 14, 16, 18, 20, 22])
# read in text from file help.html in the directory of this module
self.LoadFile('help.html')
if not self.LoadFile('help.html'):
self.LoadFile('/usr/share/doc/quisk/help.html')
def OnGraphData(self, data):
pass
def ChangeYscale(self, y_scale):
@@ -6136,7 +6157,7 @@ class App(wx.App):
self.freqDisplay.Display(self.txFreq + self.VFO)
self.fldigi_new_freq = self.txFreq + self.VFO
return change
def ChangeRxTxFrequency(self, rx_freq=None, tx_freq=None):
def ChangeRxTxFrequency(self, rx_freq=None, tx_freq=None, keep_mode=False):
if not self.split_rxtx and not tx_freq:
tx_freq = rx_freq
if tx_freq:
@@ -6147,7 +6168,7 @@ class App(wx.App):
else: # Change the VFO
vfo = (tx_freq // 5000) * 5000 - 5000
tune = tx_freq - vfo
self.BandFromFreq(tx_freq)
self.BandFromFreq(tx_freq, keep_mode)
self.ChangeHwFrequency(tune, vfo, 'FreqEntry')
if rx_freq and self.split_rxtx: # Frequency must be on-screen
tune = rx_freq - self.VFO
@@ -6337,7 +6358,8 @@ class App(wx.App):
self.pttButton.Enable(False)
else:
self.pttButton.Enable(True)
def BandFromFreq(self, frequency): # Change to a new band based on the frequency
def BandFromFreq(self, frequency, keep_mode=False): # Change to a new band based on the frequency
# If keep_mode is True, do not change to the mode saved for the new band.
if self.screen == self.bandscope_screen:
return
try:
@@ -6359,7 +6381,8 @@ class App(wx.App):
vfo, tune, mode = self.bandState[band]
except KeyError:
vfo, tune, mode = (0, 0, 'LSB')
self.modeButns.SetLabel(mode, True)
if not keep_mode:
self.modeButns.SetLabel(mode, True)
self.ChangeBand(band)
break
def ChangeBand(self, band):
+30 -1
View File
@@ -172,6 +172,22 @@ tx_level = {}
# This is the maximum value of the slider.
digital_tx_level = 100
################ Receivers SuperPuper SDR, by UT3MK and US5NCJ
## hardware_file_name Hardware file path, rfile
# This is the file that contains the control logic for each radio.
#hardware_file_name = 'ut3mkpkg/quisk_hardware.py'
## widgets_file_name Widget file path, rfile
# This optional file adds additional controls for the radio.
#widgets_file_name = ''
## sdr1000_serial_port Serial port, text
# This is serial Port for the synthesizer, usually /dev/ttyUSBn on Linux or COMn on Win
sdr1000_serial_port = "/dev/ttyUSB0"
## sdr1000_serial_rate Serial baud rate, integer
# This is baud rate for the synthesizer.
sdr1000_serial_rate = 57600
################ Receivers HiQSDR, The original N2ADR and HiQSDR radios.
@@ -425,7 +441,7 @@ hermes_PTT_hang_time = 4
# This option only applies to the Hermes Lite 2. Set this to None if you don't have a tuner.
# Set this to "Tune" to control the Icom AH-4 compatible ATU attached to the Hermes Lite 2 end plate.
# Note that this option only applies to the HL2 end plate, not the IO board.
# A tuner attached to the IO board is controlled from a button on the last line of the screen.
# A tuner attached to the IO board is controlled from a button on the last line of the screen.
# Then when the Spot button is pressed with a positive power, a tune request is sent to the ATU.
# If the Spot button is pressed with a zero power level, the tuner is set to bypass mode.
# Changes are immediate (no need to restart).
@@ -1508,6 +1524,19 @@ remote_radio_ip = ""
# It is only necessary to enter it once on each computer.
remote_radio_password = ""
## remote_radio_mic_jitter_ms Mic buffer msec for remote radio, integer
# This is set on the remote radio, not the control head. The network can hold the control
# head's microphone packets for 100 msec or more and then deliver them in a burst, which is
# common over Wi-Fi. The transmit audio then has gaps. This buffers the microphone audio
# for the given time before transmitting it, so the bursts are smoothed out, at the cost
# of that much more transmit delay. Zero turns the buffer off, which is best on a wired
# network. If needed, try 150 msec. The maximum is 500. A change takes effect when the
# next control head connects.
remote_radio_mic_jitter_ms = 0
#remote_radio_mic_jitter_ms = 100
#remote_radio_mic_jitter_ms = 150
#remote_radio_mic_jitter_ms = 250
## k4_tcp_ip IP address for K4 TCP, text
# This is the Quisk IP address for the TCP server implementing K4 commands.
k4_tcp_ip = ""
+23
View File
@@ -1,6 +1,29 @@
from __future__ import print_function
from __future__ import division
def wheel_step(rotation, delta, step):
"""Convert signed wheel rotation into configured frequency steps.
A measured downward event reported rotation=-180 with delta=120. Scaling
that by a 50 Hz step gives -75 Hz; flooring the resulting absolute frequency
made downward tuning jump -100 Hz while upward tuning moved only +50 Hz.
Round the wheel units symmetrically first: +/-180 now gives +/-50 Hz, while
+/-240 still gives +/-100 Hz rather than being collapsed to one step.
"""
if delta == 0:
return 0
rotation = int(rotation)
delta = int(delta)
step = int(step)
direction = -1 if rotation * delta < 0 else 1
whole_steps, remainder = divmod(abs(rotation), abs(delta))
if remainder * 2 > abs(delta):
whole_steps += 1
return direction * whole_steps * step
class SplineInterpolator: # From Numerical Recipes in C
"""Interpolate a table of [x, y] values."""
def __init__(self, table, Xmin=None, Xmax=None):
+4 -3
View File
@@ -26,6 +26,7 @@ import threading, webbrowser
import _quisk as QS
from quisk_widgets import *
import configure
import quisk_utils
DEBUG = 0
@@ -630,7 +631,7 @@ class GraphScreen(wx.Window):
mouse_x, mouse_y = self.GetMousePosition(event)
self.SetTxFreq(index=mouse_x - self.originX)
def OnWheel(self, event):
tune = self.display.tune_tx + event.GetWheelRotation() // event.GetWheelDelta()
tune = self.display.tune_tx + quisk_utils.wheel_step(event.GetWheelRotation(), event.GetWheelDelta(), 1)
self.SetTxFreq(index=tune)
class HelpScreen(wx.html.HtmlWindow):
@@ -640,8 +641,8 @@ class HelpScreen(wx.html.HtmlWindow):
if "gtk2" in wx.PlatformInfo:
self.SetStandardFonts()
self.SetFonts("", "", [10, 12, 14, 16, 18, 20, 22])
# read in text from file help.html in the directory of this module
self.LoadFile('help_vna.html')
if not self.LoadFile('help_vna.html'):
self.LoadFile('/usr/share/doc/quisk/help_vna.html')
def OnLinkClicked(self, link):
webbrowser.open(link.GetHref(), new=2)
+1 -1
View File
@@ -82,7 +82,7 @@ N1MM+ and software that uses Hamlib.
'quisk.hermes', 'quisk.hiqsdr', 'quisk.afedrinet', 'quisk.soapypkg',
'quisk.sdrmicronpkg', 'quisk.perseuspkg', 'quisk.ac2yd', 'quisk.multuspkg'],
package_dir = {'quisk' : '.'},
package_data = {'' : ['*.txt', '*.html', '*.so', '*.dll'], 'quisk' : ['icons/*']},
package_data = {'' : ['*.txt', '*.html', '*.so', '*.dll'], 'quisk' : ['icons/*']},
entry_points = {'gui_scripts' : ['quisk = quisk.quisk:main', 'quisk_vna = quisk.quisk_vna:main']},
ext_modules = Modules,
provides = ['quisk'],
+2
View File
@@ -0,0 +1,2 @@
This directory has files for the SDR-1000 clone synthesizer by
UT3MK and US5NCJ
+1
View File
@@ -0,0 +1 @@
#
+523
View File
@@ -0,0 +1,523 @@
# -*- coding: utf-8 -*-
from __future__ import print_function
import serial, time, logging
import re
import _quisk as QS
import quisk_conf_defaults as conf
import sys, wx, traceback
# ── ПОСЛЕДОВАТЕЛЬНЫЙ ПОРТ ДЛЯ CAT ───────────────────────────────────
sdr1000_serial_port = "/dev/ttyUSB0" # Windows: "COM3" / Linux: "/dev/ttyUSB0"
sdr1000_serial_rate = 57600 # PowerSDR CAT: до 115200
# ── ЛОГ ────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-5s %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("SDR1000")
Mode2Index = {'CWL':0, 'CWU':1, 'LSB':2, 'USB':3, 'AM':4,
'FM':5, 'EXT':6, 'DGT-U':7, 'DGT-L':8, 'DGT-IQ':9,
'IMD':10, 'FDV-U':11, 'FDV-L':12, 'DGT-FM':13}
# ── КЛАСС УПРАВЛЕНИЯ ЖЕЛЕЗОМ ────────────────────────────────────────
from quisk_hardware_model import Hardware as BaseHardware
class Hardware(BaseHardware):
"""
Quisk Hardware-класс для FlexRadio SDR-1000.
Использует CAT-команды протокола PowerSDR (ZZ + Kenwood).
"""
# Буфер для приема данных с com-порта
serialbuf = '';
# Буфер для отправки команд на com-порт
cmdbuf = '';
# Буфер отправки IF
statusbuf ='';
statussending = False;
# Сопоставление размера шагов
STEP_MAP = {
'1': '0000', '10': '0001', '50': '1000', '100': '0010',
'250': '1001', '500': '1010', '1k': '0011', '5k': '1011',
'9k': '1100', '10k': '0100', '100k':'0101', '1M': '0110',
'10M': '0111',
}
STEP_VAL = {
'1': 1,
'10': 10,
'50': 50,
'100': 100,
'250': 250,
'500': 500,
'1k': 1000,
'5k': 5000,
'9k': 9000,
'10k': 10000,
'100k': 100000,
'1M': 1000000,
'10M': 10000000,
}
# STEP_ENA = ['1', '10', '50', '100', '250', '500', '1k', '5k', '10k', '100k' ]
STEP_ENA = [ '10', '50', '100', '250', '500', '1k', '5k', '10k', '100k' ]
BAND_ENA = ['80', '40', '30', '20', '17', '15', '12', '10']
watchdog = 0
watchdog_limit = 100
# Сопоставление режимов Quisk → код MD (PowerSDR/Kenwood)
# TODO
MODE_MAP = {
'LSB': '1', 'USB': '2', 'CWU': '3', 'FM': '4',
'AM': '5', 'DGT-L': '6', 'CWL': '7',
# Quisk-специфичные → стандартные
'DGT-U':'2', 'FMN': '4', 'AMN': '5',
'DGT-FM': '4', 'FDV-U':'2', 'FDV-L':'1',
'FSK' : '9', 'FSKR' : '2',
}
def __init__(self, app, conf):
BaseHardware.__init__(self, app, conf)
self.ser = None
self.current_freq = 0
self.current_vfo = 0
self.current_mode = 'USB'
self.current_band = '80'
self.current_step = '50'
self.rf_gain_labels = ('RF -20', 'RF -10', 'RF 0', 'RF +10' )
self.current_pre = 0
self.current_ritfreq = 0
self.rit_active = False
self.ptt_active = False
# ── Открытие/закрытие ────────────────────────────────────────────
def open(self):
"""Вызывается один раз при старте Quisk."""
try:
self.ser = serial.Serial(
sdr1000_serial_port,
sdr1000_serial_rate,
timeout=0, xonxoff=True,
)
log.debug("SDR-1000: COM-порт открыт %s @ %d",
sdr1000_serial_port, sdr1000_serial_rate)
except serial.SerialException as e:
log.error("SDR-1000: не удалось открыть порт: %s", e)
self.ser = None
return "SDR-1000: ошибка порта %s" % sdr1000_serial_port
msg = "SDR-1000 подключен (%s, %s)" % (
sdr1000_serial_port, "PowerSDR")
return msg
def close(self):
"""Вызывается один раз при завершении Quisk."""
if self.ptt_active:
self.ser.cmdbuf += 'RX;' # снять передачу
if self.ser and self.ser.is_open:
self.cmdbuf = ''
self.ser.flush
self.ser.close()
log.debug("SDR-1000: порт закрыт")
# ── Низкоуровневый CAT-обмен ────────────────────────────────────
def _cat(self):
"""
Вызываться будет периодически для чтения порта
"""
if not self.ser or not self.ser.is_open:
return ""
try:
#self.ser.write(frame)
if self.ser.in_waiting > 0:
sdata = self.ser.read(size=self.ser.in_waiting)
self.serialbuf += sdata.decode('ascii')
else:
self.watchdog += 1
if self.watchdog > self.watchdog_limit:
log.debug("Watchdog! Resetting arduino")
self.ser.dtr = 0
time.sleep(0.1)
self.ser.dtr = 1
self.ser.flush
self.ser.close()
time.sleep(0.1)
try:
self.ser = serial.Serial(
sdr1000_serial_port,
sdr1000_serial_rate,
timeout=0, xonxoff=True,
)
log.debug("SDR-1000: COM-порт открыт %s @ %d",
sdr1000_serial_port, sdr1000_serial_rate)
except serial.SerialException as e:
log.error("SDR-1000: не удалось открыть порт: %s", e)
self.ser = None
return "SDR-1000: ошибка порта %s" % sdr1000_serial_port
self.ser.flushInput()
self.ser.flushOutput()
self.watchdog = 0
ans = ''
while ';' in self.serialbuf:
cmds = self.serialbuf.partition(';')
cmd = cmds[0]
self.serialbuf = cmds[-1]
# log.info("Splitted to %s and %s", cmd, self.serialbuf)
if len(cmd) > 0:
answer = ''
cmddone = 0
match cmd:
case 'IF':
# Запрос полного статуса
# Возврат в statusbuf
self.statusbuf = self.ReturnInfo()
log.debug("Status refresh, buf: %s", self.serialbuf)
cmddone = 1
case 'ZZSM0':
# Запрос показаний S-метра
ans = self.ReturnSmeterA()
log.debug("S-meter refresh, buf: %s", ans)
cmddone = 1
case 'PS1':
# Включение
ans = 'PS1;'
log.debug("PowerON, buf: %s", ans)
cmddone = 1
case 'ZZPA':
# Запрос показаний PRE ! TODO смена режима
ans = self.ReturnPre()
log.debug("PRE answer, buf: %s", ans)
cmddone = 1
case 'UP':
# Запрос на увеличение частоты
self.application.ChangeHwFrequency(9000,
self.current_vfo+self.STEP_VAL[self.current_step])
cmddone = 1
case 'DN':
# Запрос на уменьшение частоты
self.application.ChangeHwFrequency(9000,
self.current_vfo-self.STEP_VAL[self.current_step])
cmddone = 1
case 'ZZSU':
# Запрос на увеличение шага
self.StepInc()
cmddone = 1
case 'ZZSD':
# Запрос на уменьшение шага
self.StepDec()
cmddone = 1
case 'BU':
# Запрос смены диапазона
self.BandUp()
cmddone = 1
case 'BD':
# Запрос смены диапазона
self.BandDn()
cmddone = 1
case 'TX':
# Transmit
QS.set_PTT(1)
self.ptt_active = True
log.info("PTT ON")
cmddone = 1
case 'RX':
# Recieve
QS.set_PTT(0)
self.ptt_active = False
log.info("PTT OFF")
cmddone = 1
case 'ZZQR':
# Запрос восстановление QuickSave
cmddone = 1
if len(cmd) > 4 and not cmddone:
log.debug("Try to match cmd: %s, buf: %s", cmd[0:4], self.serialbuf)
match cmd[0:4]:
case 'ZZPA':
# Запрос изменения PRE
self.SetPre(cmd)
ans = self.ReturnPre()
log.debug("PRE set, buf: %s", ans)
cmddone = 1
case 'ZZMD':
# Запрос изменения режима
self.SetMode(cmd)
log.debug("Mode set, buf: %s", cmd)
cmddone = 1
case 'FA00':
# Запрос изменения частоты
self.SetFreq(cmd)
log.debug("Freq set, buf: %s", cmd)
cmddone = 1
if not cmddone:
log.info("unknown cmd: %s, buf: %s", cmd, self.serialbuf)
ans = '?;'
if len(ans) > 0:
log.debug("Sending answer, buf: %s", ans)
self.ser.write(ans.encode('ascii'))
if len(self.serialbuf) == 0:
if len(self.cmdbuf) > 0:
cmds = self.cmdbuf.partition(';')
cmd = cmds[0]
self.cmdbuf = cmds[-1]
log.debug("Sending cmd %s, buf: %s", cmd, self.cmdbuf)
self.ser.write(cmd.encode('ascii'))
if (len(self.statusbuf) > 0) and not self.statussending:
self.statussending = True
log.debug("Sending status, buf: %s", self.statusbuf)
self.ser.write(self.statusbuf.encode('ascii'))
self.statusbuf = ''
self.statussending = False
# End of CAT
except Exception as e:
log.error("SDR-1000 CAT error: %s", e)
return ""
@staticmethod
def _fmt_freq(hz):
"""Частота → 11 цифр: 14200000 → '00014200000'."""
return "%011d" % int(hz)
# ── Quisk API: частота ──────────────────────────────────────────
def ChangeFrequency(self, tune, vfo, source='', band='', event=None):
"""Quisk вызывает при смене частоты. Возвращает (tune, vfo)."""
if tune and tune != self.current_freq:
self.current_freq = tune
self.current_vfo = tune-9000
if re.search('FA\\d{11};', self.cmdbuf,0):
log.debug("--> RE MATCH")
re.sub('FA\\d{11};', "FA" + self._fmt_freq(tune)+";", self.cmdbuf, 1)
# self.cmdbuf += ("FA" + self._fmt_freq(tune)+";")
else:
self.cmdbuf += ("FA" + self._fmt_freq(tune)+";")
log.debug("VFO A → %d Hz (%.6f MHz)", self.current_vfo, self.current_freq / 1e6)
return self.current_freq, self.current_vfo
def ReturnInfo(self):
ritstr = self.application.ritFreq
if ritstr >= 0:
ritsign = "+"
else:
ritsign = ""
answer = "IF%011d%4s%s%05d%01d0000%01d%1s0000000;" % (
self.current_freq, self.STEP_MAP[self.current_step],
ritsign,ritstr,self.application.ritButton.GetValue(),
self.ptt_active,self.MODE_MAP[self.current_mode])
# log.info(answer)
return answer
def ReturnSmeterA(self):
i = round((self.application.smeter_db + 67) * 2)
if i < 0:
i = 0
elif i > 260:
i = 260
answer = "ZZSM0%03d;" % ( i )
# log.info(answer)
return answer
def ReturnPre(self):
answer = "ZZPA%01d;" % ( self.current_pre )
return answer
def SetPre(self, precmd):
if len(precmd) == 5:
match precmd:
case 'ZZPA0': i = 0
case 'ZZPA1': i = 1
case 'ZZPA2': i = 2
case 'ZZPA3': i = 3
case _: i = 0
self.current_pre = i
def SetFreq(self, freqcmd):
if len(freqcmd) == 13:
newfreq = int(freqcmd[2:])
log.info("Request to change freq: %d", newfreq)
self.current_vfo=newfreq-9000
self.application.ChangeHwFrequency(9000, self.current_vfo)
def OnButtonRfGain(self, event):
btn = event.GetEventObject()
n = btn.index
self.current_pre = n
self.cmdbuf += ("ZZPA%1d;" % self.current_pre)
def SetMode(self, modecmd):
if len(modecmd) == 6:
log.info("Request to change mode: %s", modecmd[4:])
match modecmd[4:]:
# 00 = LSB => LSB
# 01 = USB => USB
# 02 = DSB => USB
# 03 = CWL => CW
# 04 = CWU => CW
# 05 = FMN => FMN
# 06 = AM => AM
# 07 = DIGU => FSKR
# 08 = SPEC => AMN
# 09 = DIGL => FSK
# 10 = SAM => USB
# 11 = DRM => USB
case '00': newmode = 'LSB'
case '01': newmode = 'USB'
case '02': newmode = 'USB'
case '03': newmode = 'CWL'
case '04': newmode = 'CWU'
case '05': newmode = 'FM'
case '06': newmode = 'AM'
case '07': newmode = 'DGT-U'
case '08': newmode = 'AM'
case '09': newmode = 'DGT-L'
case '10': newmode = 'USB'
case '11': newmode = 'USB'
case _: newmode = 'USB'
self.current_mode = newmode
self.application.mode = self.current_mode
self.application.MakeFilterButtons(self.application.Mode2Filters(self.current_mode))
QS.set_rx_mode(Mode2Index.get(self.current_mode, 3))
if self.current_mode == 'CWL':
self.application.SetRit(conf.cwTone)
elif self.current_mode == 'CWU':
self.application.SetRit(-conf.cwTone)
else:
self.application.SetRit(0)
if self.current_mode in ('CWL', 'CWU'):
self.application.SetFilterByMode('CW')
elif self.current_mode in ('LSB', 'USB'):
self.application.SetFilterByMode('SSB')
elif self.current_mode == 'AM':
self.application.SetFilterByMode('AM')
elif self.current_mode[0:2] == 'FM':
self.application.SetFilterByMode('FM')
elif self.current_mode[0:4] == 'DGT-':
self.application.SetFilterByMode('DGT')
self.application.sliderSquelch.DeleteSliderWindow()
if self.current_mode == 'FM':
self.application.sliderSquelch.SetSlider(self.application.levelSquelch)
else:
self.application.sliderSquelch.SetSlider(self.application.levelSquelchSSB)
self.application.OnBtnSquelch()
if self.current_mode not in ('FDV-L', 'FDV-U'):
self.application.graph.SetDisplayMsg()
self.application.waterfall.SetDisplayMsg()
self.application.SetTxAudio()
def StepInc(self):
i = self.STEP_ENA.index(self.current_step)
log.debug("Found index of %s, idx: %d", self.current_step, i)
if i < (len(self.STEP_ENA)-1):
self.current_step = self.STEP_ENA[i + 1]
log.debug("Step changed to %s, idx: %d", self.current_step, i+1)
else:
log.debug("Step not changed, remain %s", self.current_step)
def StepDec(self):
i = self.STEP_ENA.index(self.current_step)
log.info("Found index of %s, idx: %d", self.current_step, i)
if i > 0:
self.current_step = self.STEP_ENA[i - 1]
log.debug("Step changed to %s, idx: %d", self.current_step, i-1)
else:
log.debug("Step not changed, remain %s", self.current_step)
def BandUp(self):
i = self.BAND_ENA.index(self.current_band)
log.debug("Found index of %s, idx: %d", self.current_band, i)
if i < (len(self.BAND_ENA)-1):
self.current_band = self.BAND_ENA[i + 1]
else:
self.current_band = self.BAND_ENA[0]
log.info("Band changed to %s, idx: %d", self.current_band, i)
vfo, tune, mode = self.application.bandState[self.current_band]
self.application.ChangeHwFrequency(9000, vfo+tune)
def BandDn(self):
i = self.BAND_ENA.index(self.current_band)
log.debug("Found index of %s, idx: %d", self.current_band, i)
if i > 0:
self.current_band = self.BAND_ENA[i - 1]
else:
self.current_band = self.BAND_ENA[len(self.BAND_ENA)-1]
log.info("Band changed to %s, idx: %d", self.current_band, i)
vfo, tune, mode = self.application.bandState[self.current_band]
self.application.ChangeHwFrequency(9000, vfo+tune)
def ReturnFrequency(self):
"""Опрос ~10 Гц. Возвращает (tune, vfo) или (None, None)."""
# SDR-1000 не сообщает частоту сам, поэтому возвращаем None
return None, None
# ── Quisk API: режим ─────────────────────────────────────────────
def ChangeMode(self, mode):
"""Quisk вызывает при смене режима. mode: 'USB', 'CW', и т.д."""
# Нормализуем DGT-* → USB
# if mode.startswith('DGT'):
# mode = 'USB'
code = self.MODE_MAP.get(mode)
if not code:
log.warning("Неизвестный режим Quisk: %s → по умолчанию USB", mode)
code = '2'
self.cmdbuf += ("MD" + code+';')
self.current_mode = mode
log.debug("Режим → %s (MD%s)", mode, code)
# ── Quisk API: диапазон ─────────────────────────────────────────
def ChangeBand(self, band):
"""Quisk вызывает при смене диапазона. band: '160', '80', …"""
BaseHardware.ChangeBand(self, band)
log.info("Диапазон → %s м", band)
# ── Quisk API: PTT ──────────────────────────────────────────────
def OnButtonPTT(self, event=None):
"""Quisk вызывает при нажатии/отпускании PTT."""
if event:
self.ptt_active = bool(event.GetEventObject().GetValue())
#self._cat("ZZTX%d" % (1 if self.ptt_active else 0))
log.debug("PTT %s", "ON" if self.ptt_active else "OFF")
# ── Quisk API: HeartBeat (~10 Гц) ───────────────────────────────
def HeartBeat(self):
"""Периодический опрос — можно использовать для синхронизации."""
self._cat()
def FastHeartBeat(self): # Called frequently by the GUI thread
pass
# ── Quisk API: TUNE ─────────────────────────────────────────────
def OnBtnFDX(self, is_fdx):
"""Кнопка FDX (полный дуплекс)."""
log.debug("FDX → %s", is_fdx)