diff --git a/ios/FT8AF/FT8AF/Engine/LiveEngine.swift b/ios/FT8AF/FT8AF/Engine/LiveEngine.swift index 32e6a382..1ca143e1 100644 --- a/ios/FT8AF/FT8AF/Engine/LiveEngine.swift +++ b/ios/FT8AF/FT8AF/Engine/LiveEngine.swift @@ -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 } // Log the TX message to conversation panel. let utcTime = Self.utcTimeString(from: Int64(Date().timeIntervalSince1970 * 1000)) @@ -331,6 +340,7 @@ final class LiveEngine { self?.appState?.tx.isTransmitting = false } } + return true } // MARK: - Waterfall loop diff --git a/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift b/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift index 60cc4a4d..57074553 100644 --- a/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift +++ b/ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift @@ -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() @@ -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() @@ -115,6 +130,7 @@ public final class QsoEngine { case .bye73: pendingTx = "\(dx) \(myCall) 73" stage = .bye73 + bye73Sent = false case .cq, .idle: break // handled above } } @@ -140,6 +156,7 @@ 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 @@ -147,10 +164,16 @@ public final class QsoEngine { 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 } @@ -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 } diff --git a/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift b/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift index a3ddbada..44cfd029 100644 --- a/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift +++ b/ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift @@ -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()