Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions ios/FT8AF/FT8AF/Engine/LiveEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -309,15 +309,24 @@ final class LiveEngine {
// desktop engine's `maybe_transmit`), not the next slot's.
if let txMsg = qso.txMessage {
if SlotClock.shouldTransmit(slotID: slotID, desiredParity: appState.tx.slotParity) {
scheduleTx(message: txMsg, freqHz: appState.waterfall.txFreqHz)
if scheduleTx(message: txMsg, freqHz: appState.waterfall.txFreqHz) {
// Tell the sequencer the message actually went out. This is what
// lets a courtesy "73" wrap the QSO up only after it's on the
// air; if scheduleTx bailed (encode failure) the QSO stays armed
// and retransmits next slot instead of clobbering the 73.
qso.notifyTransmitted()
}
}
}
}

/// Generate FT8 audio and play it through the speaker.
private func scheduleTx(message: String, freqHz: Float) {
guard let appState else { return }
guard let samples = FT8Encoder.generateFT8(message, baseFreqHz: freqHz) else { return }
/// Generate FT8 audio and play it through the speaker. Returns `true` if a
/// transmission was actually started, `false` if it bailed (encode failure) —
/// the caller uses this to tell the sequencer the message really went out.
@discardableResult
private func scheduleTx(message: String, freqHz: Float) -> Bool {
guard let appState else { return false }
guard let samples = FT8Encoder.generateFT8(message, baseFreqHz: freqHz) else { return false }
Comment on lines +323 to +329

// Log the TX message to conversation panel.
let utcTime = Self.utcTimeString(from: Int64(Date().timeIntervalSince1970 * 1000))
Expand All @@ -331,6 +340,7 @@ final class LiveEngine {
self?.appState?.tx.isTransmitting = false
}
}
return true
}

// MARK: - Waterfall loop
Expand Down
30 changes: 27 additions & 3 deletions ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ public final class QsoEngine {
private var reportRcvd: Int?
private var pendingTx: String?
private var startedMs: Int64 = 0
/// True once the courtesy "73" queued in `.bye73` has actually been handed to
/// the transmitter (via `notifyTransmitted`). Guards `finishQso` so a Bye73
/// that missed its slot (late TX window, rig busy, encode failure) is
/// retransmitted next slot instead of being clobbered by the wrap-up.
private var bye73Sent = false

public init(myCall: String, myGrid: String) {
self.myCall = myCall.uppercased()
Expand All @@ -74,6 +79,16 @@ public final class QsoEngine {
/// The message queued for the next TX slot (nil when idle).
public var txMessage: String? { pendingTx }

/// The scheduler calls this after the current `txMessage` has actually been
/// handed to the transmitter. It only matters for the courtesy "73": it marks
/// the Bye73 as sent so the next `processRx` may wrap the QSO up. For every
/// other stage the sequencer advances on received replies, so this is a no-op.
public func notifyTransmitted() {
if stage == .bye73 {
bye73Sent = true
}
}

/// Begin calling CQ.
public func startCq() {
resetQso()
Expand Down Expand Up @@ -115,6 +130,7 @@ public final class QsoEngine {
case .bye73:
pendingTx = "\(dx) \(myCall) 73"
stage = .bye73
bye73Sent = false
case .cq, .idle: break // handled above
}
}
Expand All @@ -140,17 +156,24 @@ public final class QsoEngine {
reportSent = nil
reportRcvd = nil
startedMs = FT8Time.nowUnixMs()
bye73Sent = false
}

/// Process the decoded messages of a finished RX slot. Updates the queued TX
/// message for the next slot and returns a completion outcome if a QSO ended.
public func processRx(_ messages: [DecodedMessage]) -> QsoOutcome? {
if !active || stage == .idle { return nil }

// The courtesy "73" queued on the previous slot has now had its TX slot;
// wrap up (return to CQ / stop) regardless of incoming traffic this slot.
// A courtesy "73" is queued (RReport -> RR73, or a manual Tx5). Only wrap
// up (return to CQ / stop) once it has *actually* been transmitted — the
// scheduler signals that via `notifyTransmitted`. If it hasn't gone out yet
// (missed TX window, rig busy, encode failure), leave the message queued so
// the scheduler retransmits it next slot instead of clobbering it. Either
// way we don't act on incoming traffic while a 73 is pending.
if stage == .bye73 {
finishQso()
if bye73Sent {
finishQso()
}
return nil
}

Expand Down Expand Up @@ -208,6 +231,7 @@ public final class QsoEngine {
// Acknowledge with 73, then log.
pendingTx = "\(dx) \(myCall) 73"
stage = .bye73
bye73Sent = false
return complete(dx)
default: break
}
Expand Down
55 changes: 55 additions & 0 deletions ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,66 @@ final class QsoEngineTests: XCTestCase {
// The final 73 is queued (not clobbered by the auto-return)...
XCTAssertEqual(e.txMessage, "K1ABC K0XYZ 73")
XCTAssertEqual(e.status().stage, .bye73)
// ...the scheduler actually transmits it (signalled here)...
e.notifyTransmitted()
// ...and once its TX slot has passed, the next slot returns to CQ.
XCTAssertNil(e.processRx([]))
XCTAssertEqual(e.txMessage, "CQ K0XYZ EN37")
}

func testBye73NotFinishedUntilActuallyTransmitted() {
// Regression: processRx must not wrap up on stage == .bye73 alone. If the
// courtesy 73 couldn't be transmitted this slot (late TX window, rig busy,
// encode failure) the QSO must stay armed and retransmit it, not clobber it
// by returning to CQ. Ported from desktop qso.rs
// `bye73_not_finished_until_actually_transmitted`.
let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37")
e.answer(msg("CQ", "K1ABC", "FN42", "FN42", -5))
_ = e.processRx([msg("K0XYZ", "K1ABC", "-12", "", -8)])
XCTAssertEqual(e.txMessage, "K1ABC K0XYZ R-08")

// DX sends RR73 -> QSO logs and the courtesy 73 is queued.
let out = e.processRx([msg("K0XYZ", "K1ABC", "RR73", "", -8)])
guard case .completed? = out else { return XCTFail("expected completed QSO") }
XCTAssertEqual(e.txMessage, "K1ABC K0XYZ 73")
XCTAssertEqual(e.status().stage, .bye73)

// The 73 did NOT go out this slot (no notifyTransmitted). The next slot
// must keep the 73 queued rather than returning to CQ, and must not log the
// QSO a second time.
XCTAssertNil(e.processRx([]))
XCTAssertEqual(e.txMessage, "K1ABC K0XYZ 73")
XCTAssertEqual(e.status().stage, .bye73)
// Still pending after another silent slot.
XCTAssertNil(e.processRx([]))
XCTAssertEqual(e.txMessage, "K1ABC K0XYZ 73")
XCTAssertEqual(e.status().stage, .bye73)

// Once the scheduler reports the 73 actually transmitted, the next slot
// wraps the QSO up and returns to CQ.
e.notifyTransmitted()
XCTAssertNil(e.processRx([]))
XCTAssertEqual(e.txMessage, "CQ K0XYZ EN37")
XCTAssertEqual(e.status().stage, .cq)
}

func testNotifyTransmittedIsNoOpOffBye73() {
// notifyTransmitted only latches the courtesy 73; it must not disturb an
// in-progress sequence (e.g. after answering, stage == .grid). Ported from
// desktop qso.rs `notify_transmitted_is_noop_off_bye73`.
let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37")
e.answer(msg("CQ", "K1ABC", "FN42", "FN42", -5))
XCTAssertEqual(e.status().stage, .grid)
e.notifyTransmitted()
// Still awaiting the DX report; nothing changed.
XCTAssertEqual(e.txMessage, "K1ABC K0XYZ EN37")
XCTAssertEqual(e.status().stage, .grid)
// The DX report still advances us normally.
_ = e.processRx([msg("K0XYZ", "K1ABC", "-12", "", -8)])
XCTAssertEqual(e.txMessage, "K1ABC K0XYZ R-08")
XCTAssertEqual(e.status().stage, .rReport)
}

func testInitiatorCqSequenceLogsQso() {
let e = QsoEngine(myCall: "K0XYZ", myGrid: "EN37")
e.startCq()
Expand Down
Loading