Skip to content

fix: serial PTT — Win32 WaitCommEvent path for FTDI DSR detection#2147

Merged
ten9876 merged 6 commits intoten9876:mainfrom
chibondking:fix/serial-ptt-stuck-tx
Apr 30, 2026
Merged

fix: serial PTT — Win32 WaitCommEvent path for FTDI DSR detection#2147
ten9876 merged 6 commits intoten9876:mainfrom
chibondking:fix/serial-ptt-stuck-tx

Conversation

@chibondking
Copy link
Copy Markdown
Collaborator

QSerialPort::pinoutSignals() wraps GetCommModemStatus(), which returns
stale zeros on FTDI VCP drivers. The FTDI driver only refreshes modem
status in the context of a WaitCommEvent completion — not from a
background poll. Adding EV_DSR to Qt's comm mask does not help because
Qt owns the port handle exclusively and its own WaitCommEvent loop
consumes the completions.

Fix: on Windows, bypass QSerialPort entirely. Open the COM port with
CreateFile (synchronous, non-overlapped) and dedicate a QThread to a
blocking WaitCommEvent loop. When EV_DSR or EV_CTS fires, read
GetCommModemStatus immediately in that completion context (where FTDI
guarantees fresh data) and marshal the result back to the controller
thread via QueuedConnection.

The Linux/macOS QSerialPort polling path is unchanged.

Also fix MainWindow destructor: call SerialPortController::close() on
the ExtControllers thread via BlockingQueuedConnection before quitting
the thread, then moveToThread back to main before deletion. Prevents
the "Cannot send events to objects owned by a different thread" fatal
assert seen on app exit.

Also fix spurious PTT on open: only deassert DTR/RTS pins that have an
assigned PTT/CW function. Unassigned pins stay HIGH (OS default),
assert seen on app exit.

Also fix spurious PTT on open: only deassert DTR/RTS pins that have an
assigned PTT/CW function. Unassigned pins stay HIGH (OS default),
providing the source voltage for foot switch circuits that loop an
output pin back to DSR/CTS.

Also fix phantom PTT edge on polarity change: in-place settings update
now re-samples pin state after polarity flip so the next event/poll
doesn't see a false edge.

Tested: FTDI-based foot switch on COM17, DSR active-high. Clean press
and release edges with contact-bounce debouncing at 20 ms.

Copy link
Copy Markdown
Contributor

@aethersdr-agent aethersdr-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, @chibondking. The FTDI WaitCommEvent workaround is well-researched and the platform split is clean. A few observations:

Looks good

  • Win32 WaitCommEvent loop: Correct pattern — synchronous blocking call on a dedicated thread, SetCommMask(0) to abort on close, QueuedConnection to marshal back. The non-overlapped approach is the right call for FTDI VCP compatibility.
  • MainWindow destructor fix: BlockingQueuedConnection to close the port on the worker thread before quit() is the correct ordering to avoid the cross-thread assert. moveToThread(currentThread) before delete is also correct.
  • Spurious PTT on open fix: Only deasserting pins with assigned functions (leaving unassigned pins HIGH for foot-switch source voltage) is a thoughtful change that matches real wiring.
  • Polarity re-sample: Re-sampling after a polarity flip in loadSettings() prevents the false-edge bug cleanly.
  • Uses AppSettings throughout (not QSettings).

Items to consider

  1. static locals in pollInputPins() (non-Windows path): The added diagnostic logging uses static QSerialPort::PinoutSignals lastRaw{} and static bool lastDebounceOk{} — these are process-global, not per-instance. There's only one SerialPortController today, so it works, but if a second instance were ever created (e.g. multi-radio), they'd share state. Consider making these m_ members instead, or at minimum adding a comment noting the single-instance assumption.

  2. Win32 GetCommState/SetCommState failures are silent: If SetCommState fails after GetCommState succeeds, the handle is closed but no errorOccurred signal is emitted and no diagnostic is logged. Adding a qCWarning + emit errorOccurred(...) there (similar to the CreateFileW failure path) would help users diagnose misconfigured baud/framing.

  3. HAVE_SERIALPORT on Windows: The PR correctly assumes HAVE_SERIALPORT is still defined on Windows builds (since Qt6::SerialPort ships on Windows), making the MainWindow #ifdef HAVE_SERIALPORT guards around the new destructor code and the serialSettingsChanged connection correct. Worth a brief comment in SerialPortController.h noting that on Windows, HAVE_SERIALPORT is expected to be defined but the QSerialPort code path is not used — just so a future reader doesn't wonder whether the ifdefs are wrong.

  4. RadioSetupDialog signal: The new serialSettingsChanged() signal is not guarded by #ifdef HAVE_SERIALPORT, which is fine (unused signals are harmless), but it does diverge from the existing pattern where buildSerialTab() is guarded. No action needed — just noting for consistency awareness.

None of the above are blockers. This is solid platform work with good error handling and thorough testing notes. Thanks for tracking down the FTDI GetCommModemStatus staleness issue — that's the kind of driver-level knowledge that saves people hours of debugging.

chibondking and others added 4 commits April 29, 2026 00:51
  QSerialPort::pinoutSignals() wraps GetCommModemStatus(), which returns
  stale zeros on FTDI VCP drivers. The FTDI driver only refreshes modem
  status in the context of a WaitCommEvent completion — not from a
  background poll. Adding EV_DSR to Qt's comm mask does not help because
  Qt owns the port handle exclusively and its own WaitCommEvent loop
  consumes the completions.

  Fix: on Windows, bypass QSerialPort entirely. Open the COM port with
  CreateFile (synchronous, non-overlapped) and dedicate a QThread to a
  blocking WaitCommEvent loop. When EV_DSR or EV_CTS fires, read
  GetCommModemStatus immediately in that completion context (where FTDI
  guarantees fresh data) and marshal the result back to the controller
  thread via QueuedConnection.

  The Linux/macOS QSerialPort polling path is unchanged.

  Also fix MainWindow destructor: call SerialPortController::close() on
  the ExtControllers thread via BlockingQueuedConnection before quitting
  the thread, then moveToThread back to main before deletion. Prevents
  the "Cannot send events to objects owned by a different thread" fatal
  assert seen on app exit.

  Also fix spurious PTT on open: only deassert DTR/RTS pins that have an
  assigned PTT/CW function. Unassigned pins stay HIGH (OS default),
  providing the source voltage for foot switch circuits that loop an
  output pin back to DSR/CTS.

  Also fix phantom PTT edge on polarity change: in-place settings update
  now re-samples pin state after polarity flip so the next event/poll
  doesn't see a false edge.

  Tested: FTDI-based foot switch on COM17, DSR active-high. Clean press
  and release edges with contact-bounce debouncing at 20 ms.
- Promote static locals lastRaw/lastDebounceOk in pollInputPins() to
  m_lastRawPins/m_lastDebounceLogged members — static locals are
  process-global and would share state across instances
- Add qCWarning + emit errorOccurred() on GetCommState/SetCommState
  failures in Win32 open() — previously silent, now consistent with
  the CreateFileW failure path
- Add comment in SerialPortController.h clarifying that HAVE_SERIALPORT
  is defined on Windows but the QSerialPort code path is bypassed by
  the Win32 WaitCommEvent path

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@chibondking chibondking force-pushed the fix/serial-ptt-stuck-tx branch from 474ec64 to 3622cab Compare April 29, 2026 05:52
@chibondking
Copy link
Copy Markdown
Collaborator Author

@AetherClaude changes applied based on feedback

@aethersdr-agent
Copy link
Copy Markdown
Contributor

Thanks @chibondking — I've read through the updated diff. The overall approach is solid: the Win32 WaitCommEvent path correctly addresses the FTDI stale-modem-status issue, the destructor fix with BlockingQueuedConnection + moveToThread is the right pattern, and the selective pin deassertion on open is a good fix for the spurious PTT problem.

A couple of things I noticed:

1. Stale queued event after close() (minor race)

In the Win32 path, close() calls SetCommMask(0) to abort the pending WaitCommEvent, then waits for the watcher thread to exit. But there may already be a processWinPinChange lambda queued via QueuedConnection that hasn't been delivered yet. Since close() runs on the controller's thread, that queued invocation won't fire during close() — it fires the next time the event loop spins. At that point m_hWin is null and m_lastCtsActive/m_lastDsrActive have been reset to false, but processWinPinChange doesn't check isOpen(), so it could re-set those fields and emit a stale PTT signal.

Easy fix — add a guard at the top of processWinPinChange:

void SerialPortController::processWinPinChange(bool dsrRaw, bool ctsRaw)
{
    if (!isOpen()) return;
    // ...
}

2. Diagnostic logging volume in the polling path

The new per-poll logging block in pollInputPins() logs on every pin-state change or every debounce-state transition. With a 10 ms poll interval and a bouncy foot switch, this could produce a burst of log lines. Not a correctness issue, but worth keeping in mind — if it turns out to be noisy in practice, gating it behind a qCDebug category threshold or sampling (e.g., log at most once per second) would help.

Everything else looks good. The watcher thread lifecycle is clean (atomic flag + SetCommMask(0) abort + wait), the QueuedConnection marshal keeps all state mutation on the controller's thread, and the initial pin sampling in both paths prevents false edges on open. Nice work on the loadSettings() in-place update path too — re-sampling after a polarity flip is the right call to avoid phantom edges.

chibondking and others added 2 commits April 29, 2026 02:52
  A QueuedConnection invocation of processWinPinChange could be
  delivered after close() — after m_hWin is nulled and pin state
  fields are reset — because Qt defers cross-thread signals until
  the next event-loop spin. Without the guard the stale delivery
  would re-set m_lastCtsActive/m_lastDsrActive and emit a phantom
  externalPttChanged edge. Add an isOpen() early-return to make
  any post-close delivery a no-op.
Adds a clarifying comment near the Qt6::SerialPort find_package call
explaining that the Windows path bypasses Qt6::SerialPort for raw
Win32 WaitCommEvent (per the SerialPortController fix in this PR).

Side-effect: touching CMakeLists.txt activates the path filter that
gates check-windows in .github/workflows/ci.yml so this otherwise
src/-only PR actually exercises a Windows compile.  The PR is
near-entirely Windows-specific code, so a clean MSVC build is the
test we most want before merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@ten9876 ten9876 merged commit 0350311 into ten9876:main Apr 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants