From bb001b4d38b0b3c63e95a830307f03776076b094 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Thu, 8 Oct 2026 02:26:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(logbook):=20ADIF=20=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E6=96=B9=E5=90=91=E4=BF=AE=E6=AD=A3=E2=80=94?= =?UTF-8?q?=E2=80=94VUCC=5FGRIDS=20=E5=BD=92=E5=AF=B9=E6=96=B9=E3=80=81MY?= =?UTF-8?q?=5FVUCC=5FGRIDS=20=E5=BD=92=E5=B7=B1=E6=96=B9=EF=BC=88=E7=BC=96?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E5=8F=8C=E5=90=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../look4sat/core/domain/logbook/AdifCodec.kt | 29 ++++++- .../look4sat/core/domain/logbook/QsoModels.kt | 2 + .../core/domain/logbook/AdifCodecTest.kt | 80 +++++++++++++++++-- 3 files changed, 99 insertions(+), 12 deletions(-) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt index e48ff990..fe98d00a 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodec.kt @@ -14,9 +14,15 @@ import java.util.Date import java.util.Locale import java.util.TimeZone -/** Small ADI codec for the logbook's actual field set (ADIF 3.1.7). */ +/** + * Small ADI codec for the logbook's actual field set (ADIF 3.1.7). + * + * Grid directions follow ADIF: the un-prefixed fields (GRIDSQUARE, VUCC_GRIDS) belong to + * the OPPOSITE station, the MY_* fields (MY_GRIDSQUARE, MY_VUCC_GRIDS) to the operator. + */ object AdifCodec { private val utc = TimeZone.getTimeZone("UTC") + private val gridPattern = Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?") fun encode(records: List, includeStationCallsign: Boolean = true): String = buildString { append(field("ADIF_VER", "3.1.7")) @@ -36,7 +42,17 @@ object AdifCodec { appendOptional("MODE", record.mode) appendOptional("SUBMODE", record.submode) appendOptional("GRIDSQUARE", record.theirGrid) + // The OPPOSITE station's multi-grid set (2+ grids: a grid line or corner). + // A single grid is carried by GRIDSQUARE; one value inside the VUCC field + // makes Wavelog flag the record in its QSO view. + if (record.theirVuccGrids.size > 1) { + appendOptional("VUCC_GRIDS", record.theirVuccGrids.joinToString(",")) + } appendOptional("MY_GRIDSQUARE", record.myGrid) + // The grid set this qso went out under (own operated grids, stamped at + // upload). ADIF's MY_VUCC_GRIDS is its home — not VUCC_GRIDS, which belongs + // to the opposite station and drives Wavelog's grid/distance/QRB display. + appendOptional("MY_VUCC_GRIDS", record.vuccGrids.joinToString(",")) appendOptional("RST_SENT", record.sentReport) appendOptional("RST_RCVD", record.receivedReport) record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) } @@ -49,7 +65,6 @@ object AdifCodec { if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y")) if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y")) appendOptional("LOTW_QSLRDATE", record.lotwQslDate) - appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(",")) record.dxcc?.let { append(field("DXCC", it.toString())) } appendOptional("COUNTRY", record.country) record.cqZone?.let { append(field("CQZ", it.toString())) } @@ -80,7 +95,9 @@ object AdifCodec { theirCallsign = call, myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US), theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US), + theirVuccGrids = decodeGrids(values["VUCC_GRIDS"]), myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US), + vuccGrids = decodeGrids(values["MY_VUCC_GRIDS"]), sentReport = values["RST_SENT"].orEmpty(), receivedReport = values["RST_RCVD"].orEmpty(), txFrequencyHz = mhzToHz(values["FREQ"]), @@ -100,8 +117,6 @@ object AdifCodec { lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true), lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true), lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(), - vuccGrids = values["VUCC_GRIDS"].orEmpty().split(',').map { it.trim().uppercase(Locale.US) } - .filter { it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }.distinct(), dxcc = values["DXCC"]?.toIntOrNull(), country = values["COUNTRY"].orEmpty(), cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 }, @@ -140,6 +155,12 @@ object AdifCodec { return records } + /** Splits an ADIF grid list on commas, keeping only valid 4/6/8-char locators. */ + private fun decodeGrids(value: String?): List = value.orEmpty().split(',') + .map { it.trim().uppercase(Locale.US) } + .filter { it.matches(gridPattern) } + .distinct() + private fun StringBuilder.appendOptional(name: String, value: String) { if (value.isNotBlank()) append(field(name, value)) } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt index 9d99c96c..fc4b763c 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt @@ -48,6 +48,8 @@ data class QsoRecord( val wavelogStation: String = "", val lotwReceived: Boolean = false, val lotwQslDate: String = "", + /** Own operated grid set this QSO went out under — uploaded as ADIF MY_VUCC_GRIDS + * (never VUCC_GRIDS, which belongs to the opposite station). Stamped at upload. */ val vuccGrids: List = emptyList(), val dxcc: Int? = null, val country: String = "", diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt index 9a72fa72..2a0941f8 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/AdifCodecTest.kt @@ -10,6 +10,7 @@ package com.rtbishop.look4sat.core.domain.logbook import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -22,8 +23,10 @@ class AdifCodecTest { endUtcMillis = 1_700_000_000_100L, theirCallsign = "BG5JVM", myCallsign = "BA7OPF", - theirGrid = "OM60", + theirGrid = "EN52", + theirVuccGrids = listOf("EN52", "EN53"), myGrid = "OL62TI", + vuccGrids = listOf("OL62", "OL61"), sentReport = "59", receivedReport = "59", txFrequencyHz = 145_850_000L, @@ -36,8 +39,7 @@ class AdifCodecTest { propagationMode = "SAT", status = QsoStatus.COMPLETE, lotwConfirmed = true, - lotwQslDate = "2026-09-20", - vuccGrids = listOf("OM60", "OM50") + lotwQslDate = "2026-09-20" ) val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single() assertEquals(record.startUtcMillis, decoded.startUtcMillis) @@ -47,12 +49,18 @@ class AdifCodecTest { assertEquals("2M", decoded.band) assertEquals("70CM", decoded.rxBand) assertTrue(decoded.lotwConfirmed) - assertEquals(listOf("OM60", "OM50"), decoded.vuccGrids) + // Grid directions survive a round trip: the opposite's set stays theirs, + // the operator's own set stays theirs. + assertEquals("EN52", decoded.theirGrid) + assertEquals(listOf("EN52", "EN53"), decoded.theirVuccGrids) + assertEquals("OL62TI", decoded.myGrid) + assertEquals(listOf("OL62", "OL61"), decoded.vuccGrids) } @Test fun decode_lotwStyleReportFields() { - // LoTW report subset: lowercase eor, alphabetical fields, VUCC_GRIDS list. + // LoTW report subset: lowercase eor, alphabetical fields. The un-prefixed + // grid fields belong to the OPPOSITE station, MY_* fields to the operator. val adi = """ BH6RJD @@ -64,8 +72,9 @@ class AdifCodecTest { 2M 70CM OM60 - OM60IL70 OM60,OM50 + OL62TI + OL62,OL61 BH6RJD 318 China @@ -78,12 +87,27 @@ class AdifCodecTest { assertEquals("BH6RJD", record.theirCallsign) assertEquals("SO-50", record.satelliteName) assertEquals("FM", record.displayMode) - assertEquals("OM60IL70", record.myGrid) - assertEquals(listOf("OM60", "OM50"), record.vuccGrids) + assertEquals("OM60", record.theirGrid) + assertEquals(listOf("OM60", "OM50"), record.theirVuccGrids) + assertEquals("OL62TI", record.myGrid) + assertEquals(listOf("OL62", "OL61"), record.vuccGrids) assertTrue(record.lotwConfirmed) assertTrue(record.isSatellite) } + @Test + fun decodeWavelogPullStyleRecordKeepsGridDirections() { + // Wavelog's export: the OPPOSITE station's grids un-prefixed, the station + // profile's grid under MY_*. A pull must not flip them into each other. + val adi = "BG5JVM202609160745" + + "EN52,EN53OL62TIOL62,OL61" + + "FMSATSO-50" + val record = AdifCodec.decode(adi).single() + assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids) + assertEquals("OL62TI", record.myGrid) + assertEquals(listOf("OL62", "OL61"), record.vuccGrids) + } + @Test fun encodeOmitsStationCallsignForWavelog() { val record = QsoRecord( @@ -99,4 +123,44 @@ class AdifCodecTest { assertTrue(adif.contains("N0CALL")) assertTrue(adif.contains("SO-50")) } + + @Test + fun encodeKeepsOwnGridsOutOfTheOppositeVuccField() { + // The reported defect: an operator's stamped own grid set must never land in + // VUCC_GRIDS — Wavelog displays that field as the OPPOSITE station's grid. + val record = QsoRecord( + startUtcMillis = 1_700_000_000_000L, + theirCallsign = "N0CALL", + myCallsign = "BA7OPF", + myGrid = "OL62TI", + vuccGrids = listOf("OL62"), + satelliteName = "SO-50" + ) + val adif = AdifCodec.encode(listOf(record), includeStationCallsign = false) + assertTrue(adif.contains("OL62")) + assertFalse(adif.contains("EN52")) + assertTrue(multiAdif.contains("EN52,EN53")) + + // A single opposite grid rides in GRIDSQUARE only; one value inside the VUCC + // field would trigger Wavelog's single-grid warning in its QSO view. + val single = multi.copy(theirVuccGrids = listOf("EN52")) + val singleAdif = AdifCodec.encode(listOf(single), false) + assertTrue(singleAdif.contains("EN52")) + assertFalse(singleAdif.contains("