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
11 changes: 10 additions & 1 deletion ios/FT8AFKit/Sources/FT8Engine/QsoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -286,14 +286,23 @@ func clampReport(_ snr: Int) -> Int { min(30, max(-30, snr)) }
func fmtReport(_ n: Int) -> String { String(format: "%+03d", Int32(n)) }

private func classify(_ msg: DecodedMessage) -> Content {
if !msg.grid.isEmpty { return .grid(msg.grid) }
// Recognize the QSO sign-offs before treating the message as a grid report.
// "RR73" is a valid 4-char Maidenhead-shaped token (R,R in A..R + two
// digits), so the decoder copies it into `grid` (looksLikeGrid("RR73") is
// true). Checking grid first would classify a partner's standard RR73
// sign-off as Content.grid, stranding the auto-sequencer at the RReport
// stage — it would retransmit our R-report forever and never complete/log
// the QSO. Matching the sign-offs first fixes that without touching
// looksLikeGrid (the map/distance code relies on its shape test). Mirrors
// the desktop (qso.rs) and Android (GeneralVariables) reference ports.
let extra = msg.extra.trimmingCharacters(in: .whitespacesAndNewlines)
switch extra {
case "RR73": return .rr73
case "RRR": return .rrr
case "73": return .bye73
default: break
}
if !msg.grid.isEmpty { return .grid(msg.grid) }
if extra.hasPrefix("R") {
let rest = String(extra.dropFirst())
if let n = Int(rest) { return .rReport(n) }
Expand Down
36 changes: 36 additions & 0 deletions ios/FT8AFKit/Tests/FT8EngineTests/QsoEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,42 @@ final class QsoEngineTests: XCTestCase {
XCTAssertEqual(e.txMessage, "CQ K0XYZ EN37")
}

func testRr73WithDecoderGridStillCompletesQso() {
// Regression for the sign-off-vs-grid classification bug (ported from
// desktop qso.rs `rr73_with_decoder_grid_still_completes_qso`). Live, the
// DX's RR73 arrives with grid == "RR73" because looksLikeGrid("RR73") is
// true and the decoder copies it into the grid field. If classify checked
// grid first it returned Content.grid, the RReport arm hit `default:
// break`, and the QSO hung retransmitting our R-report forever instead of
// logging.
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")
XCTAssertEqual(e.status().stage, .rReport)

// DX signs off with RR73 exactly as the decoder emits it live (grid set).
let out = e.processRx([msg("K0XYZ", "K1ABC", "RR73", "RR73", -8)])
guard case .completed(let rec)? = out else {
return XCTFail("RR73 sign-off must complete the QSO")
}
XCTAssertEqual(rec.call, "K1ABC")
XCTAssertEqual(rec.gridsquare, "FN42")
XCTAssertEqual(rec.rstSent, "-08")
XCTAssertEqual(rec.rstRcvd, "-12")
XCTAssertEqual(e.txMessage, "K1ABC K0XYZ 73")
XCTAssertEqual(e.status().stage, .bye73)

// A genuine 4-char grid reply is still classified as a grid, not a
// sign-off — the fix reorders the checks without weakening grid handling.
// As the CQ initiator, an answerer's grid must advance us to send a report.
let e2 = QsoEngine(myCall: "K0XYZ", myGrid: "EN37")
e2.startCq()
_ = e2.processRx([msg("K0XYZ", "K1ABC", "FN31", "FN31", -10)])
XCTAssertEqual(e2.txMessage, "K1ABC K0XYZ -10")
XCTAssertEqual(e2.status().stage, .report)
}

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