From d70043282937c4f7d780d5ad125dcdf4ee119d71 Mon Sep 17 00:00:00 2001 From: Rui Oliveira Date: Fri, 24 Jul 2026 15:23:34 +0100 Subject: [PATCH 1/8] Add initial support for Icom IC-705 radio - Added support for Icom IC-705 radio model with split mode functionality. - Updated RadioTrackingService to handle single-radio split mode and dual-radio configurations. - Modified MainContainer to provide appropriate radio controllers based on selected model. - Enhanced SettingsRepo to include split mode preference in radio control settings. - Updated UI in SettingsDialog to allow toggling of split mode for IC-705 and adjust device selection accordingly. - Improved handling of baud rates based on selected radio model. - Added extended operations in IRadioController for IC-705 specific commands. --- .../core/data/framework/Ic705Controller.kt | 281 ++++++++++ .../core/data/framework/IcomCivProtocol.kt | 307 ++++++++++ .../data/framework/RadioTrackingService.kt | 524 +++++++++++------- .../core/data/injection/MainContainer.kt | 24 +- .../core/data/repository/SettingsRepo.kt | 7 +- .../look4sat/core/domain/model/Settings.kt | 18 +- .../domain/repository/IRadioController.kt | 30 + .../feature/settings/SettingsDialog.kt | 180 +++--- 8 files changed, 1103 insertions(+), 268 deletions(-) create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt new file mode 100644 index 000000000..860df4d5c --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt @@ -0,0 +1,281 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.data.framework + +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothSocket +import android.util.Log +import com.rtbishop.look4sat.core.domain.repository.IRadioController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.io.OutputStream +import java.util.UUID + +/** + * Icom IC-705 CI-V controller over Bluetooth SPP. + * + * The IC-705 emits broadcast frames continuously (band scope, UTC, signal + * level, …). A reply to any command we send may therefore be buried in + * that noise. All response reads drain up to [ACK_TIMEOUT_MS] and scan the + * entire accumulated buffer for the frame we expect rather than assuming + * the very next byte is the response. + */ +class Ic705Controller( + private val bluetoothManager: BluetoothManager, + private val deviceAddress: String +) : IRadioController { + + private val tag = "IC705" + private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb") + private val ioMutex = Mutex() + + /** Time budget (ms) to wait for a response amid broadcast noise. */ + private val ACK_TIMEOUT_MS = 500L + /** Polling interval while draining the input buffer. */ + private val POLL_INTERVAL_MS = 20L + /** Small pause after writing a command before reading the response. */ + private val WRITE_SETTLE_MS = 50L + + private var socket: BluetoothSocket? = null + private var outputStream: OutputStream? = null + private var inputStream: InputStream? = null + + override var isConnected: Boolean = false + private set + + // ── Connection ────────────────────────────────────────────────────────── + + override suspend fun connect(): Boolean = withContext(Dispatchers.IO) { + if (isConnected) return@withContext true + if (deviceAddress.isBlank()) return@withContext false + try { + val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress) + val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId) + btSocket.connect() + socket = btSocket + outputStream = btSocket.outputStream + inputStream = btSocket.inputStream + isConnected = true + Log.i(tag, "Connected to $deviceAddress") + true + } catch (e: Exception) { + Log.e(tag, "Connect error: ${e.message}") + isConnected = false + false + } + } + + override suspend fun disconnect() = withContext(Dispatchers.IO) { + try { + inputStream?.close() + outputStream?.close() + socket?.close() + } catch (e: Exception) { + Log.e(tag, "Disconnect error: ${e.message}") + } finally { + inputStream = null + outputStream = null + socket = null + isConnected = false + Log.i(tag, "Disconnected from $deviceAddress") + } + } + + // ── IRadioController – standard operations ────────────────────────────── + + override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) { + Log.d(tag, "setFrequency: ${frequencyHz}Hz") + ioMutex.withLock { + val cmd = IcomCivProtocol.buildSetFreqCommand(frequencyHz) + Log.d(tag, "CMD setFreq → ${IcomCivProtocol.toHex(cmd)}") + sendAndWaitAck(cmd) + } + } + + override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) { + val cmd = IcomCivProtocol.buildSetModeCommand(mode) ?: run { + Log.w(tag, "setMode: unknown mode '$mode'") + return@withContext false + } + Log.d(tag, "setMode: $mode") + Log.d(tag, "CMD setMode → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) { + Log.d(tag, "setCtcssMode: $enabled") + val cmd = IcomCivProtocol.buildCtcssModeCommand(enabled) + Log.d(tag, "CMD ctcssMode → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) { + Log.d(tag, "setCtcssTone: ${toneHz}Hz") + val cmd = IcomCivProtocol.buildSetCtcssToneCommand(toneHz) + Log.d(tag, "CMD ctcssTone → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + override suspend fun readFrequencyAndMode(): Pair? = withContext(Dispatchers.IO) { + ioMutex.withLock { + val cmd = IcomCivProtocol.buildReadFreqCommand() + Log.d(tag, "CMD readFreq → ${IcomCivProtocol.toHex(cmd)}") + val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_READ_FREQ) ?: return@withContext null + // Read-freq reply payload: [cmd byte already stripped by parseResponse] [5 freq bytes] [mode] [filter] + IcomCivProtocol.parseFreqModePayload(payload).also { + if (it != null) Log.d(tag, "readFreqMode: ${it.first}Hz, ${it.second}") + else Log.w(tag, "readFreqMode: parse failed, payload=${IcomCivProtocol.toHex(payload)}") + } + } + } + + override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) { + Log.w(tag, "pttOn: PTT control not used for IC-705 (radio is master)") + true + } + + override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) { + Log.w(tag, "pttOff: PTT control not used for IC-705 (radio is master)") + true + } + + // ── IRadioController – IC-705 extended operations ─────────────────────── + + /** + * Read the current PTT (TX/RX) status from the radio. + * Returns **true** if the radio is currently transmitting, **false** if + * receiving, **null** on error. + */ + override suspend fun readPttStatus(): Boolean? = withContext(Dispatchers.IO) { + ioMutex.withLock { + val cmd = IcomCivProtocol.buildReadTxStatusCommand() + Log.d(tag, "CMD readPTT → ${IcomCivProtocol.toHex(cmd)}") + val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_TX_STATUS) ?: return@withContext null + IcomCivProtocol.parseTxStatus(payload).also { + Log.d(tag, "PTT status: ${if (it == true) "TX" else if (it == false) "RX" else "unknown"}") + } + } + } + + /** + * Select VFO-A (main) or VFO-B (sub). + * @param vfoA true → VFO-A, false → VFO-B + */ + override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) { + val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand() + else IcomCivProtocol.buildSelectVfoBCommand() + Log.d(tag, "CMD selectVFO${if (vfoA) "A" else "B"} → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + /** + * Enable or disable SPLIT mode (TX on sub-VFO while listening on main VFO). + */ + override suspend fun setSplitMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) { + val cmd = IcomCivProtocol.buildSplitModeCommand(enabled) + Log.d(tag, "CMD split ${if (enabled) "ON" else "OFF"} → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + /** + * Set the frequency of the **currently active** VFO (CMD 0x25 sub 0x00). + * In split mode the radio automatically switches active VFO on PTT, so + * always writing to the active VFO is the correct strategy. + */ + override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) { + Log.d(tag, "setWorkingFrequency: ${frequencyHz}Hz") + val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz) + Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + // ── Internal I/O helpers ──────────────────────────────────────────────── + + /** + * Write [cmd] to the radio and drain the input stream for up to + * [ACK_TIMEOUT_MS], looking for an OK/NG acknowledgement frame. + */ + private suspend fun sendAndWaitAck(cmd: ByteArray): Boolean { + if (!write(cmd)) return false + delay(WRITE_SETTLE_MS) + val buf = drainWithTimeout(ACK_TIMEOUT_MS) + val ok = IcomCivProtocol.containsAck(buf) + if (!ok) Log.w(tag, "ACK not found in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}") + return ok + } + + /** + * Write [cmd] to the radio and drain the input stream for up to + * [ACK_TIMEOUT_MS], scanning for a response frame carrying [expectCmd]. + * Returns the payload bytes of that frame, or null on timeout/error. + */ + private suspend fun sendAndReadResponse(cmd: ByteArray, expectCmd: Byte): ByteArray? { + if (!write(cmd)) return null + delay(WRITE_SETTLE_MS) + val buf = drainWithTimeout(ACK_TIMEOUT_MS) + val response = IcomCivProtocol.parseResponse(buf, expectCmd) + if (response == null) { + Log.w(tag, "No response for cmd 0x${String.format("%02X", expectCmd.toInt() and 0xFF)} " + + "in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}") + } + return response?.payload + } + + /** + * Drain whatever bytes the radio has buffered within a [timeoutMs] window. + * Uses non-blocking `available()` polling to avoid blocking indefinitely. + */ + private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray { + val result = mutableListOf() + val deadline = System.currentTimeMillis() + timeoutMs + val stream = inputStream ?: return ByteArray(0) + while (System.currentTimeMillis() < deadline) { + try { + val available = stream.available() + if (available > 0) { + val chunk = ByteArray(available) + val read = stream.read(chunk) + if (read > 0) result.addAll(chunk.take(read)) + } else { + delay(POLL_INTERVAL_MS) + } + } catch (e: Exception) { + Log.e(tag, "Drain error: ${e.message}") + isConnected = false + break + } + } + return result.toByteArray() + } + + private fun write(bytes: ByteArray): Boolean { + return try { + outputStream?.write(bytes) + outputStream?.flush() + true + } catch (e: Exception) { + Log.e(tag, "Write error: ${e.message}") + isConnected = false + false + } + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt new file mode 100644 index 000000000..a851e3f5f --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt @@ -0,0 +1,307 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.data.framework + +import java.util.Locale + +/** + * Icom CI-V protocol encoder/decoder for the IC-705. + * + * Frame structure: + * FE FE [] [] FD + * + * IC-705 default CI-V address : 0xA4 + * Controller (us) address : 0xE0 + */ +object IcomCivProtocol { + + // ── Framing constants ────────────────────────────────────────────────── + const val PREAMBLE: Byte = 0xFE.toByte() + const val END_OF_MSG: Byte = 0xFD.toByte() + const val ACK_OK: Byte = 0xFB.toByte() + const val ACK_NG: Byte = 0xFA.toByte() + + // ── Address constants ────────────────────────────────────────────────── + /** Default CI-V address of the IC-705. */ + const val ADDR_IC705: Byte = 0xA4.toByte() + /** Default CI-V address of the controller (us). */ + const val ADDR_CTRL: Byte = 0xE0.toByte() + + // ── Command bytes ────────────────────────────────────────────────────── + /** Read operating frequency (main VFO). */ + const val CMD_READ_FREQ: Byte = 0x03 + /** Set operating frequency (main VFO). */ + const val CMD_SET_FREQ: Byte = 0x05 + /** Set operating mode. */ + const val CMD_SET_MODE: Byte = 0x06 + /** Select VFO / memory. */ + const val CMD_SELECT_VFO: Byte = 0x07 + /** Set repeater duplex / SPLIT. */ + const val CMD_DUPLEX_SPLIT: Byte = 0x0F + /** Read/write CTCSS tone frequency. */ + const val CMD_CTCSS_TONE: Byte = 0x1B + /** Read/write misc settings (used for enabling CTCSS encode). */ + const val CMD_MISC_SETTING: Byte = 0x16 + /** Read/write TX-inhibit / transmit status. */ + const val CMD_TX_STATUS: Byte = 0x1C + /** Read/write selected-VFO frequency (cmd 0x25, sub 0x00). */ + const val CMD_SELECTED_VFO_FREQ: Byte = 0x25 + + // ── Sub-command bytes ────────────────────────────────────────────────── + /** Sub for CMD_SELECT_VFO: select VFO-A (main). */ + const val SUB_VFO_A: Byte = 0x00 + /** Sub for CMD_SELECT_VFO: select VFO-B (sub). */ + const val SUB_VFO_B: Byte = 0x01 + /** Sub for CMD_DUPLEX_SPLIT: simplex / split OFF. */ + const val SUB_SPLIT_OFF: Byte = 0x00 + /** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */ + const val SUB_SPLIT_ON: Byte = 0x01 + /** Sub for CMD_TX_STATUS: query TX/RX state. */ + const val SUB_TX_STATE: Byte = 0x00 + /** Sub for CMD_SELECTED_VFO_FREQ: selected VFO frequency. */ + const val SUB_SELECTED_VFO: Byte = 0x00 + /** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */ + const val SUB_CTCSS_SETTING: Byte = 0x42.toByte() + + // ── Mode bytes ──────────────────────────────────────────────────────── + /** Maps mode strings (upper-case) → IC-705 mode bytes. */ + val MODE_TO_BYTE: Map = mapOf( + "LSB" to 0x00, + "USB" to 0x01, + "AM" to 0x02, + "CW" to 0x03, + "RTTY" to 0x04, + "FM" to 0x05, + "WFM" to 0x06, + "CW-R" to 0x07, + "RTTY-R" to 0x08, + "DV" to 0x12 + ) + + val BYTE_TO_MODE: Map = MODE_TO_BYTE.entries.associate { it.value to it.key } + + // ── Frequency BCD encoding ───────────────────────────────────────────── + + /** + * Encode a frequency in Hz to the IC-705's 5-byte BCD format. + * + * The IC-705 uses 5 bytes, LSB pair first, with 1 Hz resolution. + * Example: 145,500,000 Hz → "0145500000" → pairs LSB→MSB: + * [00, 00, 50, 45, 01] + */ + fun encodeFrequencyBcd(frequencyHz: Long): ByteArray { + val digits = String.format(Locale.US, "%010d", frequencyHz) + val bcd = ByteArray(5) + for (i in 0 until 5) { + // digits are MSB first; we want pair index 0 = LSB pair + val pairIndex = 4 - i + val high = digits[pairIndex * 2] - '0' + val low = digits[pairIndex * 2 + 1] - '0' + bcd[i] = ((high shl 4) or low).toByte() + } + return bcd + } + + /** + * Decode 5-byte BCD frequency (LSB pair first) to Hz. + */ + fun decodeFrequencyBcd(bcd: ByteArray): Long { + // Build digit string MSB→LSB by reversing the byte order + var freqHz = 0L + for (i in 4 downTo 0) { + val b = bcd[i].toInt() and 0xFF + val high = b shr 4 + val low = b and 0x0F + freqHz = freqHz * 100 + high * 10 + low + } + return freqHz + } + + /** + * Encode a CTCSS tone (Hz, e.g. 67.0) to 2-byte BCD (0.1 Hz resolution). + * 67.0 → 670 (tenths of Hz) → BCD bytes [0x06, 0x70]. + */ + fun encodeCtcssToneBcd(toneHz: Double): ByteArray { + val tone01 = (toneHz * 10).toLong() + val digits = String.format(Locale.US, "%04d", tone01) + return byteArrayOf( + ((digits[0] - '0') shl 4 or (digits[1] - '0')).toByte(), + ((digits[2] - '0') shl 4 or (digits[3] - '0')).toByte() + ) + } + + // ── Message builders ─────────────────────────────────────────────────── + + /** Wrap payload bytes in a CI-V frame: FE FE DEST SRC ... FD. */ + private fun frame(vararg payload: Byte): ByteArray { + return byteArrayOf(PREAMBLE, PREAMBLE, ADDR_IC705, ADDR_CTRL) + + payload + + byteArrayOf(END_OF_MSG) + } + + /** Set operating frequency via CMD 0x05 (main VFO). */ + fun buildSetFreqCommand(frequencyHz: Long): ByteArray { + return frame(CMD_SET_FREQ, *encodeFrequencyBcd(frequencyHz)) + } + + /** + * Set selected-VFO frequency via CMD 0x25 sub 0x00. + * This updates whichever VFO is currently active (RX or TX after split). + */ + fun buildSetWorkingFreqCommand(frequencyHz: Long): ByteArray { + return frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO, *encodeFrequencyBcd(frequencyHz)) + } + + /** Read operating frequency (CMD 0x03). */ + fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ) + + /** Set operating mode (CMD 0x06). Filter byte 0x00 = keep current filter. */ + fun buildSetModeCommand(mode: String): ByteArray? { + val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null + return frame(CMD_SET_MODE, modeByte, 0x00) + } + + /** Select VFO-A (CMD 0x07 sub 0x00). */ + fun buildSelectVfoACommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_A) + + /** Select VFO-B (CMD 0x07 sub 0x01). */ + fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B) + + /** Enable or disable SPLIT mode (CMD 0x0F). */ + fun buildSplitModeCommand(enable: Boolean): ByteArray { + val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF + return frame(CMD_DUPLEX_SPLIT, sub) + } + + /** Query TX/RX status (CMD 0x1C sub 0x00). Radio replies with 0x00=RX, 0x01=TX. */ + fun buildReadTxStatusCommand(): ByteArray = frame(CMD_TX_STATUS, SUB_TX_STATE) + + /** + * Enable/disable CTCSS encode (CMD 0x16 sub 0x42). + * 0x01 = CTCSS encoder ON, 0x00 = OFF. + */ + fun buildCtcssModeCommand(enabled: Boolean): ByteArray { + val value: Byte = if (enabled) 0x01 else 0x00 + return frame(CMD_MISC_SETTING, SUB_CTCSS_SETTING, value) + } + + /** + * Set CTCSS tone frequency (CMD 0x1B sub 0x00). + */ + fun buildSetCtcssToneCommand(toneHz: Double): ByteArray { + val bcd = encodeCtcssToneBcd(toneHz) + return frame(CMD_CTCSS_TONE, 0x00, *bcd) + } + + // ── Response parsing ─────────────────────────────────────────────────── + + /** + * Find and parse a complete CI-V response frame from a buffer. + * + * Returns the bytes between "FE FE E0 A4 " and FD, or null if no + * complete frame was found. The search is tolerant of interleaved + * broadcast traffic. + * + * @param buf bytes accumulated from the radio + * @param expectCmd the command byte we are looking for in the reply, or + * null to accept any command response from the radio + */ + fun parseResponse(buf: ByteArray, expectCmd: Byte?): ParsedResponse? { + var i = 0 + while (i < buf.size - 5) { + // Look for FE FE preamble + if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue } + val dest = buf[i + 2] + val src = buf[i + 3] + val cmd = buf[i + 4] + // We only care about frames addressed to us from the radio + if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue } + // Find the terminating FD + val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5) + if (fdIdx < 0) break // incomplete frame, wait for more data + val payload = buf.copyOfRange(i + 5, fdIdx) + if (expectCmd == null || cmd == expectCmd) { + return ParsedResponse(cmd, payload, fdIdx + 1) + } + i = fdIdx + 1 + } + return null + } + + private fun ByteArray.indexOf(b: Byte, startIndex: Int): Int { + for (k in startIndex until size) if (this[k] == b) return k + return -1 + } + + /** + * Check whether a buffer contains an OK acknowledgement (FB FD) from + * the radio. Tolerates broadcast noise before the ACK. + */ + fun containsAck(buf: ByteArray): Boolean { + var i = 0 + while (i < buf.size - 5) { + if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue } + val dest = buf[i + 2] + val src = buf[i + 3] + val cmd = buf[i + 4] + if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue } + // Skip to FD + val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5) + if (fdIdx < 0) break + if (cmd == ACK_OK) return true + if (cmd == ACK_NG) return false + i = fdIdx + 1 + } + return false + } + + /** + * Parse frequency + mode from a CMD_READ_FREQ reply payload. + * Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte] + */ + fun parseFreqModePayload(payload: ByteArray): Pair? { + if (payload.size < 6) return null + val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5)) + val mode = BYTE_TO_MODE[payload[5]] ?: return null + return freqHz to mode + } + + /** + * Parse TX status from a CMD_TX_STATUS reply payload. + * Returns true if transmitting, false if receiving, null on error. + */ + fun parseTxStatus(payload: ByteArray): Boolean? { + if (payload.isEmpty()) return null + return when (payload[0]) { + 0x00.toByte() -> false // RX + 0x01.toByte() -> true // TX + else -> null + } + } + + /** Hex dump of bytes, useful for debug logging. */ + fun toHex(bytes: ByteArray): String = + bytes.joinToString(" ") { String.format(Locale.US, "%02X", it.toInt() and 0xFF) } + + data class ParsedResponse( + val cmd: Byte, + val payload: ByteArray, + /** Index in the source buffer immediately after the FD terminator. */ + val nextOffset: Int + ) +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt index c3f9c156f..37d05d470 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt @@ -19,8 +19,10 @@ package com.rtbishop.look4sat.core.data.framework import android.bluetooth.BluetoothManager import android.util.Log +import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.predict.OrbitalPass +import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT import com.rtbishop.look4sat.core.domain.repository.IRadioController import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo @@ -51,227 +53,377 @@ class RadioTrackingService( private var rxController: IRadioController? = null private var trackingJob: Job? = null + // ── Connection ────────────────────────────────────────────────────────── + override suspend fun connectRadios() { - // Disconnect old controllers if any txController?.disconnect() rxController?.disconnect() - // Read current addresses from settings val rcSettings = settingsRepo.radioControlSettings.value - val txAddr = rcSettings.txRadioAddress - val rxAddr = rcSettings.rxRadioAddress - - Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr") - - if (txAddr.isBlank() && rxAddr.isBlank()) { - _state.update { it.copy(errorMessage = "No radio addresses configured in Settings") } - return - } + val txAddr = rcSettings.txRadioAddress + val rxAddr = rcSettings.rxRadioAddress + val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705 + val isSplit = isIcom && rcSettings.splitMode - val tx = Ft817Controller(bluetoothManager, txAddr) - val rx = Ft817Controller(bluetoothManager, rxAddr) - txController = tx - rxController = rx + Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr") - _state.update { it.copy(errorMessage = null) } - val txOk = if (txAddr.isNotBlank()) tx.connect() else false - val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false - _state.update { - it.copy( - txConnected = txOk, - rxConnected = rxOk, - errorMessage = when { - !txOk && !rxOk -> "Could not connect to TX and RX radios" - !txOk -> "Could not connect to TX radio ($txAddr)" - !rxOk -> "Could not connect to RX radio ($rxAddr)" - else -> null - } - ) + if (isSplit) { + // Single-radio split mode: only TX slot is used + if (txAddr.isBlank()) { + _state.update { it.copy(errorMessage = "No radio address configured in Settings") } + return + } + val tx = makeController(isIcom, txAddr) + txController = tx + rxController = null + _state.update { it.copy(errorMessage = null) } + val txOk = tx.connect() + _state.update { + it.copy( + txConnected = txOk, + rxConnected = false, + errorMessage = if (!txOk) "Could not connect to radio ($txAddr)" else null + ) + } + Log.i(tag, "IC-705 split mode connected: txOk=$txOk") + } else { + if (txAddr.isBlank() && rxAddr.isBlank()) { + _state.update { it.copy(errorMessage = "No radio addresses configured in Settings") } + return + } + val tx = makeController(isIcom, txAddr) + val rx = makeController(isIcom, rxAddr) + txController = tx + rxController = rx + _state.update { it.copy(errorMessage = null) } + val txOk = if (txAddr.isNotBlank()) tx.connect() else false + val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false + _state.update { + it.copy( + txConnected = txOk, + rxConnected = rxOk, + errorMessage = when { + !txOk && !rxOk -> "Could not connect to TX and RX radios" + !txOk -> "Could not connect to TX radio ($txAddr)" + !rxOk -> "Could not connect to RX radio ($rxAddr)" + else -> null + } + ) + } + Log.i(tag, "Dual-radio connected: txOk=$txOk rxOk=$rxOk") } } + private fun makeController(isIcom: Boolean, address: String): IRadioController = + if (isIcom) Ic705Controller(bluetoothManager, address) + else Ft817Controller(bluetoothManager, address) + override suspend fun disconnectRadios() { stopTracking() txController?.disconnect() rxController?.disconnect() txController = null rxController = null - _state.update { - it.copy( - txConnected = false, - rxConnected = false, - isActive = false - ) - } + _state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) } } + // ── Tracking ──────────────────────────────────────────────────────────── + override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) { _state.update { it.copy( - isActive = true, - currentPass = pass, - selectedTransponder = transponder, - txBaseFrequencyHz = txBaseFreqHz + isActive = true, + currentPass = pass, + selectedTransponder = transponder, + txBaseFrequencyHz = txBaseFreqHz ) } trackingJob?.cancel() - trackingJob = appScope.launch { - // Set modes on both radios at tracking start - val tx = txController - val rx = rxController - val txMode = transponder.uplinkMode - val rxMode = transponder.downlinkMode - ?: transponder.uplinkMode?.let { - TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted) - } - if (tx != null && tx.isConnected && txMode != null) { - tx.setMode(txMode) - Log.i(tag, "TX mode set to $txMode") - } - if (rx != null && rx.isConnected && rxMode != null) { - rx.setMode(rxMode) - Log.i(tag, "RX mode set to $rxMode") + + val rcSettings = settingsRepo.radioControlSettings.value + val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705 + val isSplit = isIcom && rcSettings.splitMode + + if (isSplit) { + trackingJob = appScope.launch { runSplitTracking(transponder, txBaseFreqHz) } + } else { + trackingJob = appScope.launch { runDualRadioTracking(transponder, txBaseFreqHz) } + } + } + + // ── Dual-radio tracking (Yaesu or two IC-705s) ────────────────────────── + + private suspend fun runDualRadioTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) { + val tx = txController + val rx = rxController + + // Initial setup: set band/mode/CTCSS on both radios + val txMode = transponder.uplinkMode + val rxMode = transponder.downlinkMode + ?: transponder.uplinkMode?.let { + TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted) } - // Set CTCSS if FM - if (txMode?.uppercase() == "FM") { - _state.value.ctcssTone?.let { tone -> - tx?.setCtcssTone(tone) - tx?.setCtcssMode(true) - } + + Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode") + + if (tx != null && tx.isConnected && txMode != null) { + Log.d(tag, "Setting TX mode: $txMode") + tx.setMode(txMode) + } + if (rx != null && rx.isConnected && rxMode != null) { + Log.d(tag, "Setting RX mode: $rxMode") + rx.setMode(rxMode) + } + if (txMode?.uppercase() == "FM") { + _state.value.ctcssTone?.let { tone -> + Log.d(tag, "Setting CTCSS: ${tone}Hz") + tx?.setCtcssTone(tone) + tx?.setCtcssMode(true) } - _state.update { it.copy(txMode = txMode, rxMode = rxMode) } - - var lastSetTxFreq = 0.0 - var lastSetRxFreq = 0.0 - var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning - var lastReadFreq = 0L - var stableCount = 0 - - while (isActive) { - val currentState = _state.value - if (!currentState.isActive) break - - val satPass = currentState.currentPass ?: break - val xpdr = currentState.selectedTransponder ?: break - var txBaseFreq = currentState.txBaseFrequencyHz - val stationPos = settingsRepo.stationPosition.value - val timeNow = System.currentTimeMillis() - - val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow) - val tx = txController - val rx = rxController - val hasUplink = txBaseFreq != null - val c = com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT - val v = pos.distanceRate * 1000.0 - - if (tuningRadio.isNotEmpty()) { - // --- User is tuning: keep reading, wait for stabilization --- - val radio = if (tuningRadio == "tx") tx else rx - if (radio != null && radio.isConnected) { - val readResult = radio.readFrequencyAndMode() - if (readResult != null) { - val (freq, _) = readResult - if (kotlin.math.abs(freq - lastReadFreq) <= 20) { - stableCount++ - } else { - stableCount = 0 - lastReadFreq = freq - } - // Stable for 2 reads → user stopped turning - if (stableCount >= 2) { - if (tuningRadio == "tx" && txBaseFreq != null) { - val newBase = (freq.toDouble() * c / (c + v)).toLong() - if (newBase > 0) { - txBaseFreq = newBase - _state.update { it.copy(txBaseFrequencyHz = newBase) } - Log.i(tag, "TX tuning done → base=$newBase") - } - } else if (tuningRadio == "rx") { - val rxNominal = (freq.toDouble() * c / (c - v)).toLong() - val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr) - if (newTxBase != null && newTxBase > 0) { - txBaseFreq = newTxBase - _state.update { it.copy(txBaseFrequencyHz = newTxBase) } - Log.i(tag, "RX tuning done → txBase=$newTxBase") - } + } + _state.update { it.copy(txMode = txMode, rxMode = rxMode) } + + var lastSetTxFreq = 0.0 + var lastSetRxFreq = 0.0 + var tuningRadio = "" + var lastReadFreq = 0L + var stableCount = 0 + + while (isActive) { + val currentState = _state.value + if (!currentState.isActive) break + + val satPass = currentState.currentPass ?: break + val xpdr = currentState.selectedTransponder ?: break + var txBaseFreq = currentState.txBaseFrequencyHz + val stationPos = settingsRepo.stationPosition.value + val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis()) + val txNow = txController + val rxNow = rxController + val v = pos.distanceRate * 1000.0 + + if (tuningRadio.isNotEmpty()) { + val radio = if (tuningRadio == "tx") txNow else rxNow + if (radio != null && radio.isConnected) { + val read = radio.readFrequencyAndMode() + if (read != null) { + val (freq, _) = read + if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++ + else { stableCount = 0; lastReadFreq = freq } + if (stableCount >= 2) { + if (tuningRadio == "tx" && txBaseFreq != null) { + val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong() + if (newBase > 0) { + txBaseFreq = newBase + _state.update { it.copy(txBaseFrequencyHz = newBase) } + Log.i(tag, "TX tuning done → base=$newBase") + } + } else if (tuningRadio == "rx") { + val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong() + val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr) + if (newTxBase != null && newTxBase > 0) { + txBaseFreq = newTxBase + _state.update { it.copy(txBaseFrequencyHz = newTxBase) } + Log.i(tag, "RX tuning done → txBase=$newTxBase") } - tuningRadio = "" - stableCount = 0 - lastSetTxFreq = 0.0 - lastSetRxFreq = 0.0 } + tuningRadio = "" + stableCount = 0 + lastSetTxFreq = 0.0 + lastSetRxFreq = 0.0 } } - } else { - // --- Normal tracking: read, detect changes, command --- - - // TX dial feedback - if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) { - val readResult = tx.readFrequencyAndMode() - if (readResult != null) { - val (actualTxFreq, _) = readResult - if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) { - tuningRadio = "tx" - lastReadFreq = actualTxFreq - stableCount = 0 - Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)") - } - } + } + } else { + // Detect manual dial changes + if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) { + val read = txNow.readFrequencyAndMode() + if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) { + tuningRadio = "tx" + lastReadFreq = read.first + stableCount = 0 + Log.i(tag, "TX tuning detected (read=${read.first}, lastSet=$lastSetTxFreq)") } - - // RX dial feedback (only if TX not tuning) - if (tuningRadio.isEmpty() && rx != null && rx.isConnected && lastSetRxFreq > 0.0) { - val readResult = rx.readFrequencyAndMode() - if (readResult != null) { - val (actualRxFreq, _) = readResult - if (kotlin.math.abs(actualRxFreq - lastSetRxFreq) >= 20.0) { - tuningRadio = "rx" - lastReadFreq = actualRxFreq - stableCount = 0 - Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)") - } - } + } + if (tuningRadio.isEmpty() && rxNow != null && rxNow.isConnected && lastSetRxFreq > 0.0) { + val read = rxNow.readFrequencyAndMode() + if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) { + tuningRadio = "rx" + lastReadFreq = read.first + stableCount = 0 + Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)") } } + } + + val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) } + val rxBaseFreq = if (txBaseFreq != null) { + TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr) + } else xpdr.downlinkLow + val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) } - // Compute Doppler-corrected frequencies - val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) } - val rxBaseFreq = if (txBaseFreq != null) { - TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr) - } else { - xpdr.downlinkLow + if (tuningRadio.isEmpty()) { + if (txNow != null && txNow.isConnected && txRadioFreq != null) { + txNow.setFrequency(txRadioFreq) + lastSetTxFreq = txRadioFreq.toDouble() } - val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) } + if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) { + rxNow.setFrequency(rxRadioFreq) + lastSetRxFreq = rxRadioFreq.toDouble() + } + } + + _state.update { + it.copy( + txConnected = txNow?.isConnected ?: false, + rxConnected = rxNow?.isConnected ?: false, + txFrequencyHz = txRadioFreq, + rxFrequencyHz = rxRadioFreq, + azimuth = Math.toDegrees(pos.azimuth), + elevation = Math.toDegrees(pos.elevation), + distance = pos.distance + ) + } + delay(1000) + } + } + + // ── IC-705 split-radio tracking ───────────────────────────────────────── + + private suspend fun runSplitTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) { + val radio = txController ?: return + if (!radio.isConnected) return + + val txMode = transponder.uplinkMode + val rxMode = transponder.downlinkMode + ?: transponder.uplinkMode?.let { + TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted) + } + + // Compute nominal base frequencies + val txCenter = when { + transponder.uplinkLow != null && transponder.uplinkHigh != null -> + (transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2 + transponder.uplinkLow != null -> transponder.uplinkLow!! + else -> null + } + val rxNominal = if (txCenter != null) { + TransponderMapper.mapUplinkToDownlink(txCenter, transponder) + } else transponder.downlinkLow - // Command radios (only when not tuning) - if (tuningRadio.isEmpty()) { - if (tx != null && tx.isConnected && txRadioFreq != null) { - tx.setFrequency(txRadioFreq) - lastSetTxFreq = txRadioFreq.toDouble() + val txBase = initialTxBaseFreqHz ?: txCenter + Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode") + + // ── Initial setup sequence ────────────────────────────────────────── + // 1. Select VFO-A (will be RX VFO), set downlink frequency and mode + Log.d(tag, "Split init: selecting VFO-A for RX (downlink)") + radio.setVfo(vfoA = true) + if (rxNominal != null) { + Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz") + radio.setFrequency(rxNominal) + } + if (rxMode != null) { + Log.d(tag, "Split init: VFO-A mode=$rxMode") + radio.setMode(rxMode) + } + + // 2. Select VFO-B (will be TX VFO), set uplink frequency, mode, and CTCSS + Log.d(tag, "Split init: selecting VFO-B for TX (uplink)") + radio.setVfo(vfoA = false) + if (txBase != null) { + Log.d(tag, "Split init: VFO-B freq=${txBase}Hz") + radio.setFrequency(txBase) + } + if (txMode != null) { + Log.d(tag, "Split init: VFO-B mode=$txMode") + radio.setMode(txMode) + } + if (txMode?.uppercase() == "FM") { + _state.value.ctcssTone?.let { tone -> + Log.d(tag, "Split init: CTCSS=${tone}Hz") + radio.setCtcssTone(tone) + radio.setCtcssMode(true) + } + } + + // 3. Enable SPLIT — radio will now TX on VFO-B while listening on VFO-A + Log.d(tag, "Split init: enabling SPLIT mode") + radio.setSplitMode(enabled = true) + + // Return to VFO-A so the display shows RX frequency + radio.setVfo(vfoA = true) + + _state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) } + Log.i(tag, "IC-705 split init done — entering tracking loop") + + // ── Tracking loop ─────────────────────────────────────────────────── + // After initial setup we never change VFO or band. + // We only update the active-VFO frequency using CMD 0x25 sub 0x00. + // The IC-705 automatically makes VFO-B active while PTT is pressed. + while (isActive) { + val currentState = _state.value + if (!currentState.isActive) break + + val satPass = currentState.currentPass ?: break + val xpdr = currentState.selectedTransponder ?: break + val txBaseFreq = currentState.txBaseFrequencyHz + val stationPos = settingsRepo.stationPosition.value + val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis()) + val v = pos.distanceRate * 1000.0 + + // Determine Doppler-corrected frequencies + val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) } + val rxBaseCalc = if (txBaseFreq != null) { + TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr) + } else xpdr.downlinkLow + val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) } + + if (radio.isConnected) { + // Query PTT state — don't assume silence; filter broadcast noise + val isPtt = radio.readPttStatus() + Log.d(tag, "Split loop: isPTT=$isPtt txFreq=$txRadioFreq rxFreq=$rxRadioFreq") + + when (isPtt) { + true -> { + // PTT ON → radio's active VFO is VFO-B (TX) + // Update TX frequency on active VFO only + if (txRadioFreq != null) { + Log.d(tag, "Split loop TX update: ${txRadioFreq}Hz") + radio.setWorkingFrequency(txRadioFreq) + } } - if (rx != null && rx.isConnected && rxRadioFreq != null) { - rx.setFrequency(rxRadioFreq) - lastSetRxFreq = rxRadioFreq.toDouble() + false -> { + // PTT OFF → radio's active VFO is VFO-A (RX) + // Update RX frequency on active VFO only + if (rxRadioFreq != null) { + Log.d(tag, "Split loop RX update: ${rxRadioFreq}Hz") + radio.setWorkingFrequency(rxRadioFreq) + } + } + null -> { + // Could not read PTT (broadcast noise race) — skip this tick + Log.d(tag, "Split loop: PTT read failed, skipping tick") } } + } - _state.update { - it.copy( - txConnected = tx?.isConnected ?: false, - rxConnected = rx?.isConnected ?: false, - txFrequencyHz = txRadioFreq, - rxFrequencyHz = rxRadioFreq, - azimuth = Math.toDegrees(pos.azimuth), - elevation = Math.toDegrees(pos.elevation), - distance = pos.distance - ) - } - - delay(1000) + _state.update { + it.copy( + txConnected = radio.isConnected, + rxConnected = false, // single radio + txFrequencyHz = txRadioFreq, + rxFrequencyHz = rxRadioFreq, + azimuth = Math.toDegrees(pos.azimuth), + elevation = Math.toDegrees(pos.elevation), + distance = pos.distance + ) } + delay(1000) } } + // ── Other IRadioTrackingService methods ───────────────────────────────── + override fun stopTracking() { trackingJob?.cancel() trackingJob = null @@ -288,7 +440,6 @@ class RadioTrackingService( TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted) } rxMode?.let { rx?.setMode(it) } - if (transponder.uplinkMode?.uppercase() == "FM") { _state.value.ctcssTone?.let { tone -> tx?.setCtcssTone(tone) @@ -302,21 +453,17 @@ class RadioTrackingService( transponder.uplinkLow != null -> transponder.uplinkLow!! else -> null } - // Show nominal frequencies immediately val rxNominal = if (txCenter != null) { TransponderMapper.mapUplinkToDownlink(txCenter, transponder) - } else { - // Downlink-only transponder (beacon etc.) - use downlink directly - transponder.downlinkLow - } + } else transponder.downlinkLow _state.update { it.copy( selectedTransponder = transponder, - txBaseFrequencyHz = txCenter, - txFrequencyHz = txCenter, - rxFrequencyHz = rxNominal, - txMode = transponder.uplinkMode, - rxMode = transponder.downlinkMode + txBaseFrequencyHz = txCenter, + txFrequencyHz = txCenter, + rxFrequencyHz = rxNominal, + txMode = transponder.uplinkMode, + rxMode = transponder.downlinkMode ?: transponder.uplinkMode?.let { m -> TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted) } @@ -353,5 +500,4 @@ class RadioTrackingService( } _state.update { it.copy(txMode = txMode, rxMode = rxMode) } } - } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index ceafa38b9..a45e3481f 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -26,6 +26,7 @@ import androidx.room.Room import com.rtbishop.look4sat.core.data.database.Look4SatDb import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.Ft817Controller +import com.rtbishop.look4sat.core.data.framework.Ic705Controller import com.rtbishop.look4sat.core.data.framework.NetworkReporter import com.rtbishop.look4sat.core.data.framework.RadioTrackingService import com.rtbishop.look4sat.core.data.repository.DatabaseRepo @@ -39,6 +40,7 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar import com.rtbishop.look4sat.core.data.usecase.AudioCapture import com.rtbishop.look4sat.core.data.usecase.SaveImage import com.rtbishop.look4sat.core.data.usecase.ShowToast +import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IRadioController @@ -106,15 +108,25 @@ class MainContainer(private val context: Context) : IMainContainer { } override fun provideTxRadioController(): IRadioController { - val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - val address = settingsRepo.radioControlSettings.value.txRadioAddress - return Ft817Controller(manager, address) + val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val settings = settingsRepo.radioControlSettings.value + val address = settings.txRadioAddress + return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) { + Ic705Controller(manager, address) + } else { + Ft817Controller(manager, address) + } } override fun provideRxRadioController(): IRadioController { - val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - val address = settingsRepo.radioControlSettings.value.rxRadioAddress - return Ft817Controller(manager, address) + val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val settings = settingsRepo.radioControlSettings.value + val address = settings.rxRadioAddress + return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) { + Ic705Controller(manager, address) + } else { + Ft817Controller(manager, address) + } } override fun provideSensorsRepo(): ISensorsRepo { diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 42acc3187..0b54975d5 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -383,6 +383,7 @@ class SettingsRepo( private val keyTxRadioName = "txRadioName" private val keyRxRadioName = "rxRadioName" private val keyRadioBaudRate = "radioBaudRate" + private val keyRadioSplitMode = "radioSplitMode" private val _radioControlSettings = MutableStateFlow(getRadioControlSettings()) override val radioControlSettings: StateFlow = _radioControlSettings @@ -396,18 +397,20 @@ class SettingsRepo( putString(keyTxRadioName, settings.txRadioName) putString(keyRxRadioName, settings.rxRadioName) putInt(keyRadioBaudRate, settings.baudRate) + putBoolean(keyRadioSplitMode, settings.splitMode) } _radioControlSettings.value = settings } private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings( enabled = preferences.getBoolean(keyRadioControlEnabled, false), - radioModel = preferences.getString(keyRadioModel, null) ?: "Yaesu FT-817/818", + radioModel = preferences.getString(keyRadioModel, null) ?: RadioControlSettings.MODEL_YAESU_FT817, txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "", rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "", txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio", rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio", - baudRate = preferences.getInt(keyRadioBaudRate, 4800) + baudRate = preferences.getInt(keyRadioBaudRate, 4800), + splitMode = preferences.getBoolean(keyRadioSplitMode, false) ) //endregion } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index fcdc26674..edf70dd03 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -74,12 +74,20 @@ data class RadioControlSettings( val rxRadioAddress: String, val txRadioName: String, val rxRadioName: String, - val baudRate: Int + val baudRate: Int, + /** IC-705 only: use single-radio split-VFO mode instead of two radios. */ + val splitMode: Boolean = false ) { companion object { - val SUPPORTED_RADIOS = listOf( - "Yaesu FT-817/818", - "Yaesu FT-857/897" - ) + const val MODEL_YAESU_FT817 = "Yaesu FT-817/818" + const val MODEL_YAESU_FT857 = "Yaesu FT-857/897" + const val MODEL_ICOM_IC705 = "Icom IC-705" + + val SUPPORTED_RADIOS = listOf(MODEL_YAESU_FT817, MODEL_YAESU_FT857, MODEL_ICOM_IC705) + + /** Baud rates available for Yaesu radios. */ + val BAUD_RATES_YAESU = listOf(4800, 9600, 38400) + /** Baud rates available for Icom IC-705 (higher speeds supported via CI-V USB/BT). */ + val BAUD_RATES_ICOM = listOf(4800, 9600, 19200, 38400, 57600, 115200) } } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt index d0bb4ef63..e76e28e9c 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt @@ -38,4 +38,34 @@ interface IRadioController { suspend fun pttOn(): Boolean suspend fun pttOff(): Boolean + + // ── Extended operations (IC-705 / CI-V) ────────────────────────────── + + /** + * Read the current PTT status from the radio. + * Returns true if transmitting, false if receiving, null on error. + * Default: not supported. + */ + suspend fun readPttStatus(): Boolean? = null + + /** + * Select the active VFO. + * @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split). + * Default: no-op. + */ + suspend fun setVfo(vfoA: Boolean): Boolean = false + + /** + * Enable or disable SPLIT mode (TX on sub-VFO, RX on main VFO). + * Default: not supported. + */ + suspend fun setSplitMode(enabled: Boolean): Boolean = false + + /** + * Set the frequency of the currently active VFO. + * On radios that support it (IC-705: CMD 0x25 sub 0x00) this updates + * the active VFO without switching. + * Default: delegates to [setFrequency]. + */ + suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz) } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index a3a57557d..385f7a849 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -22,11 +22,14 @@ import android.content.Context import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FilterChip import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -38,6 +41,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -438,28 +442,41 @@ private fun OutputChannelSection( } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun RadioControlDialog( initialSettings: RadioControlSettings, onDismiss: () -> Unit, onSave: (RadioControlSettings) -> Unit ) { - val context = androidx.compose.ui.platform.LocalContext.current - val padding = LocalSpacing.current.large - val baudRates = listOf(4800, 9600, 38400) - val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) } + val context = androidx.compose.ui.platform.LocalContext.current + val padding = LocalSpacing.current.large + val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) } val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) } - val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) } - val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) } - val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) } - val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) } - val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) } + val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) } + val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) } + val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) } + val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) } + val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) } + val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) } val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or "" + val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705 + val isSingleRadio = isIcom && splitMode.value + + // Reset split mode when switching away from IC-705 + if (!isIcom && splitMode.value) splitMode.value = false + + val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM + else RadioControlSettings.BAUD_RATES_YAESU + + // If current baud rate is not in the new list, default to the first available + if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first() + val pairedDevices = remember { try { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList() + manager.adapter?.bondedDevices?.map { it.name ?: "Unknown" to it.address } ?: emptyList() } catch (_: SecurityException) { emptyList() } @@ -468,84 +485,119 @@ fun RadioControlDialog( val onAccept = { onSave( RadioControlSettings( - enabled = enabled.value, - radioModel = radioModel.value, + enabled = enabled.value, + radioModel = radioModel.value, txRadioAddress = txAddress.value, - rxRadioAddress = rxAddress.value, - txRadioName = txName.value, - rxRadioName = rxName.value, - baudRate = baudRate.intValue + rxRadioAddress = if (isSingleRadio) "" else rxAddress.value, + txRadioName = txName.value, + rxRadioName = if (isSingleRadio) "" else rxName.value, + baudRate = baudRate.intValue, + splitMode = splitMode.value ) ) onDismiss() } + SharedDialog( - title = stringResource(R.string.rc_settings_title), + title = stringResource(R.string.rc_settings_title), onCancel = onDismiss, onAccept = onAccept ) { Column(modifier = Modifier.padding(horizontal = padding)) { + + // Enable switch Row( horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.rc_enable_switch)) Switch(checked = enabled.value, onCheckedChange = { enabled.value = it }) } Spacer(modifier = Modifier.height(6.dp)) - // Radio model selection + // Radio model — FlowRow so chips wrap on small screens Text( - stringResource(R.string.rc_radio_model), - fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, - color = androidx.compose.material3.MaterialTheme.colorScheme.primary + text = stringResource(R.string.rc_radio_model), + fontWeight = FontWeight.Medium, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary ) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { RadioControlSettings.SUPPORTED_RADIOS.forEach { model -> - androidx.compose.material3.FilterChip( + FilterChip( selected = radioModel.value == model, - onClick = { radioModel.value = model }, - label = { Text(model, fontSize = 12.sp) }, - enabled = enabled.value + onClick = { radioModel.value = model }, + label = { Text(model, fontSize = 12.sp) }, + enabled = enabled.value ) } } Spacer(modifier = Modifier.height(6.dp)) - // TX Radio selection - Text("TX Radio (Uplink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium) + // IC-705 split-mode toggle (only shown for IC-705) + if (isIcom) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Split mode (single radio)", fontWeight = FontWeight.Medium) + Text( + text = "Use VFO-A/B split on one IC-705 instead of two radios", + fontSize = 12.sp, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = splitMode.value, + onCheckedChange = { splitMode.value = it }, + enabled = enabled.value + ) + } + Spacer(modifier = Modifier.height(6.dp)) + } + + // TX Radio (always shown; in split mode this is the single IC-705) + val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)" + Text(txLabel, fontWeight = FontWeight.Medium) if (txAddress.value.isNotBlank()) { - Text("${txName.value} - ${txAddress.value}", fontSize = 13.sp) + Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp) } CardButton( - onClick = { selectingFor.value = "tx" }, - text = "Select TX Device", + onClick = { selectingFor.value = "tx" }, + text = if (isSingleRadio) "Select Device" else "Select TX Device", modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(6.dp)) - // RX Radio selection - Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium) - if (rxAddress.value.isNotBlank()) { - Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp) + // RX Radio (hidden in split mode — the same radio handles both) + if (!isSingleRadio) { + Text("RX Radio (Downlink)", fontWeight = FontWeight.Medium) + if (rxAddress.value.isNotBlank()) { + Text("${rxName.value} — ${rxAddress.value}", fontSize = 13.sp) + } + CardButton( + onClick = { selectingFor.value = "rx" }, + text = "Select RX Device", + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(6.dp)) } - CardButton( - onClick = { selectingFor.value = "rx" }, - text = "Select RX Device", - modifier = Modifier.fillMaxWidth() - ) - // Paired devices list (shown when selecting) + // Paired device picker (inline, shown while selecting) if (selectingFor.value.isNotBlank()) { - Spacer(modifier = Modifier.height(6.dp)) Text( - text = "Paired Bluetooth Devices:", - fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, - color = androidx.compose.material3.MaterialTheme.colorScheme.primary + text = "Paired Bluetooth Devices:", + fontWeight = FontWeight.Medium, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary ) + Spacer(modifier = Modifier.height(2.dp)) if (pairedDevices.isEmpty()) { - Text("No paired devices found. Pair your BT adapter in Android Bluetooth settings first.") + Text( + "No paired devices found. Pair your BT adapter in Android Bluetooth settings first.", + fontSize = 13.sp + ) } else { pairedDevices.forEach { (name, address) -> androidx.compose.material3.Surface( @@ -554,17 +606,17 @@ fun RadioControlDialog( .clickable { if (selectingFor.value == "tx") { txAddress.value = address - txName.value = name + txName.value = name } else { rxAddress.value = address - rxName.value = name + rxName.value = name } selectingFor.value = "" } .padding(vertical = 4.dp) ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text(name, modifier = Modifier.weight(1f)) @@ -573,23 +625,19 @@ fun RadioControlDialog( } } } + Spacer(modifier = Modifier.height(6.dp)) } - Spacer(modifier = Modifier.height(6.dp)) - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text("Baud Rate:") - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - baudRates.forEach { rate -> - CardButton( - onClick = { baudRate.intValue = rate }, - text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(), - modifier = Modifier - ) - } + // Baud rate — FlowRow so all chips fit on narrow screens + Text("Baud Rate:", fontWeight = FontWeight.Medium) + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + baudRates.forEach { rate -> + FilterChip( + selected = rate == baudRate.intValue, + onClick = { baudRate.intValue = rate }, + label = { Text(rate.toString(), fontSize = 12.sp) }, + enabled = enabled.value + ) } } } From f9b1a97a37edd04727dfc32ea77105b45f63d572 Mon Sep 17 00:00:00 2001 From: Rui Oliveira Date: Fri, 24 Jul 2026 15:30:32 +0100 Subject: [PATCH 2/8] Refactor "Disconnect" and "Track" logic --- .../core/data/framework/Ic705Controller.kt | 28 ++++++++++--------- .../data/framework/RadioTrackingService.kt | 5 ++-- .../feature/settings/SettingsDialog.kt | 14 ++++++---- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt index 860df4d5c..4641e11f3 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt @@ -84,19 +84,21 @@ class Ic705Controller( } } - override suspend fun disconnect() = withContext(Dispatchers.IO) { - try { - inputStream?.close() - outputStream?.close() - socket?.close() - } catch (e: Exception) { - Log.e(tag, "Disconnect error: ${e.message}") - } finally { - inputStream = null - outputStream = null - socket = null - isConnected = false - Log.i(tag, "Disconnected from $deviceAddress") + override suspend fun disconnect() { + withContext(Dispatchers.IO) { + try { + inputStream?.close() + outputStream?.close() + socket?.close() + } catch (e: Exception) { + Log.e(tag, "Disconnect error: ${e.message}") + } finally { + inputStream = null + outputStream = null + socket = null + isConnected = false + Log.i(tag, "Disconnected from $deviceAddress") + } } } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt index 37d05d470..42ae5a5e4 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt @@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState import com.rtbishop.look4sat.core.domain.utility.TransponderMapper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -189,7 +190,7 @@ class RadioTrackingService( var lastReadFreq = 0L var stableCount = 0 - while (isActive) { + while (currentCoroutineContext().isActive) { val currentState = _state.value if (!currentState.isActive) break @@ -360,7 +361,7 @@ class RadioTrackingService( // After initial setup we never change VFO or band. // We only update the active-VFO frequency using CMD 0x25 sub 0x00. // The IC-705 automatically makes VFO-B active while PTT is pressed. - while (isActive) { + while (currentCoroutineContext().isActive) { val currentState = _state.value if (!currentState.isActive) break diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index 385f7a849..1dab74368 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -473,12 +473,14 @@ fun RadioControlDialog( // If current baud rate is not in the new list, default to the first available if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first() - val pairedDevices = remember { - try { - val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - manager.adapter?.bondedDevices?.map { it.name ?: "Unknown" to it.address } ?: emptyList() - } catch (_: SecurityException) { - emptyList() + val pairedDevices: List> = remember { + buildList { + try { + val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + manager.adapter?.bondedDevices?.forEach { + add(Pair(it.name ?: "Unknown", it.address ?: "")) + } + } catch (_: SecurityException) { } } } From dc596b4348109fd004584c75efba22672953e3d6 Mon Sep 17 00:00:00 2001 From: Rui Oliveira Date: Fri, 24 Jul 2026 15:35:43 +0100 Subject: [PATCH 3/8] Fix bugs in setting Rx frequency --- .../core/data/framework/Ic705Controller.kt | 40 ++++++++++++++++--- .../core/data/framework/IcomCivProtocol.kt | 11 ++++- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt index 4641e11f3..69d9cfe19 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt @@ -244,19 +244,25 @@ class Ic705Controller( /** * Drain whatever bytes the radio has buffered within a [timeoutMs] window. - * Uses non-blocking `available()` polling to avoid blocking indefinitely. + * Exits early as soon as a complete CI-V frame addressed to us is present + * in the buffer (i.e., FE FE E0 A4 … FD), so we don't waste the remaining + * timeout on responses that already arrived. */ private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray { - val result = mutableListOf() - val deadline = System.currentTimeMillis() + timeoutMs - val stream = inputStream ?: return ByteArray(0) + val result = mutableListOf() + val deadline = System.currentTimeMillis() + timeoutMs + val stream = inputStream ?: return ByteArray(0) while (System.currentTimeMillis() < deadline) { try { val available = stream.available() if (available > 0) { val chunk = ByteArray(available) val read = stream.read(chunk) - if (read > 0) result.addAll(chunk.take(read)) + if (read > 0) { + result.addAll(chunk.take(read)) + // Exit early once we have a complete frame for us + if (hasCompleteFrameForUs(result)) break + } } else { delay(POLL_INTERVAL_MS) } @@ -269,6 +275,30 @@ class Ic705Controller( return result.toByteArray() } + /** + * Returns true if [buf] contains a complete CI-V frame addressed to the + * controller (FE FE [ADDR_CTRL] [ADDR_IC705] … FD). + * CI-V data bytes cannot be 0xFD, so the first 0xFD after the header is + * always the frame terminator. + */ + private fun hasCompleteFrameForUs(buf: List): Boolean { + var i = 0 + while (i < buf.size - 4) { + if (buf[i] == IcomCivProtocol.PREAMBLE && + buf[i + 1] == IcomCivProtocol.PREAMBLE && + buf[i + 2] == IcomCivProtocol.ADDR_CTRL && + buf[i + 3] == IcomCivProtocol.ADDR_IC705 + ) { + for (k in i + 4 until buf.size) { + if (buf[k] == IcomCivProtocol.END_OF_MSG) return true + } + return false // header found but no FD yet + } + i++ + } + return false + } + private fun write(bytes: ByteArray): Boolean { return try { outputStream?.write(bytes) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt index a851e3f5f..73b259482 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt @@ -283,11 +283,18 @@ object IcomCivProtocol { /** * Parse TX status from a CMD_TX_STATUS reply payload. + * + * The IC-705 responds to a `1C 00` query with: + * FE FE E0 A4 1C 00 FD + * After [parseResponse] strips the frame header the payload is: + * payload[0] = 0x00 (sub-command echo) + * payload[1] = status byte (0x00 = RX, 0x01 = TX) + * * Returns true if transmitting, false if receiving, null on error. */ fun parseTxStatus(payload: ByteArray): Boolean? { - if (payload.isEmpty()) return null - return when (payload[0]) { + if (payload.size < 2) return null + return when (payload[1]) { 0x00.toByte() -> false // RX 0x01.toByte() -> true // TX else -> null From 58fb688593fd88cf6d917760b33402040e4d3c11 Mon Sep 17 00:00:00 2001 From: Rui Oliveira Date: Fri, 24 Jul 2026 15:41:34 +0100 Subject: [PATCH 4/8] Fix bugs in setting the Tx frequency Uses 0x25 01 command to set the Tx frequency, which is the correct command when in split mode. --- .../core/data/framework/Ic705Controller.kt | 13 ++++++++++++- .../core/data/framework/IcomCivProtocol.kt | 14 +++++++++++++- .../core/data/framework/RadioTrackingService.kt | 10 +++++----- .../core/domain/repository/IRadioController.kt | 12 +++++++++--- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt index 69d9cfe19..010c115ec 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt @@ -204,12 +204,23 @@ class Ic705Controller( * always writing to the active VFO is the correct strategy. */ override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) { - Log.d(tag, "setWorkingFrequency: ${frequencyHz}Hz") + Log.d(tag, "setWorkingFrequency (0x25/00): ${frequencyHz}Hz") val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz) Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}") ioMutex.withLock { sendAndWaitAck(cmd) } } + /** + * Set TX VFO frequency via CMD 0x25 sub 0x01 (unselected VFO). + * Used in split mode while PTT is pressed. + */ + override suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) { + Log.d(tag, "setTxVfoFrequency (0x25/01): ${frequencyHz}Hz") + val cmd = IcomCivProtocol.buildSetUnselectedVfoFreqCommand(frequencyHz) + Log.d(tag, "CMD setTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + // ── Internal I/O helpers ──────────────────────────────────────────────── /** diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt index 73b259482..0f400c1b6 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt @@ -73,8 +73,10 @@ object IcomCivProtocol { const val SUB_SPLIT_ON: Byte = 0x01 /** Sub for CMD_TX_STATUS: query TX/RX state. */ const val SUB_TX_STATE: Byte = 0x00 - /** Sub for CMD_SELECTED_VFO_FREQ: selected VFO frequency. */ + /** Sub for CMD_SELECTED_VFO_FREQ: selected (active) VFO frequency. */ const val SUB_SELECTED_VFO: Byte = 0x00 + /** Sub for CMD_SELECTED_VFO_FREQ: unselected (inactive / TX in split) VFO frequency. */ + const val SUB_UNSELECTED_VFO: Byte = 0x01 /** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */ const val SUB_CTCSS_SETTING: Byte = 0x42.toByte() @@ -167,6 +169,16 @@ object IcomCivProtocol { return frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO, *encodeFrequencyBcd(frequencyHz)) } + /** + * Set unselected-VFO frequency via CMD 0x25 sub 0x01. + * In split mode while PTT is pressed the IC-705 makes VFO-B active, so + * this command targets VFO-A (the RX VFO) — and vice-versa when in RX. + * Use this to update the TX VFO when PTT is on. + */ + fun buildSetUnselectedVfoFreqCommand(frequencyHz: Long): ByteArray { + return frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO, *encodeFrequencyBcd(frequencyHz)) + } + /** Read operating frequency (CMD 0x03). */ fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt index 42ae5a5e4..67f8e7701 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt @@ -387,17 +387,17 @@ class RadioTrackingService( when (isPtt) { true -> { // PTT ON → radio's active VFO is VFO-B (TX) - // Update TX frequency on active VFO only + // Use 0x25/01 (unselected VFO) to target VFO-B directly if (txRadioFreq != null) { - Log.d(tag, "Split loop TX update: ${txRadioFreq}Hz") - radio.setWorkingFrequency(txRadioFreq) + Log.d(tag, "Split loop TX update (0x25/01): ${txRadioFreq}Hz") + radio.setTxVfoFrequency(txRadioFreq) } } false -> { // PTT OFF → radio's active VFO is VFO-A (RX) - // Update RX frequency on active VFO only + // Use 0x25/00 (selected/active VFO) to update RX if (rxRadioFreq != null) { - Log.d(tag, "Split loop RX update: ${rxRadioFreq}Hz") + Log.d(tag, "Split loop RX update (0x25/00): ${rxRadioFreq}Hz") radio.setWorkingFrequency(rxRadioFreq) } } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt index e76e28e9c..89d2c83ff 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt @@ -62,10 +62,16 @@ interface IRadioController { suspend fun setSplitMode(enabled: Boolean): Boolean = false /** - * Set the frequency of the currently active VFO. - * On radios that support it (IC-705: CMD 0x25 sub 0x00) this updates - * the active VFO without switching. + * Set the frequency of the currently active VFO (CMD 0x25 sub 0x00). * Default: delegates to [setFrequency]. */ suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz) + + /** + * Set the frequency of the inactive (TX) VFO via CMD 0x25 sub 0x01. + * Used in split mode when PTT is pressed: the radio has made VFO-B + * the active VFO, so sub 0x01 targets it directly. + * Default: delegates to [setWorkingFrequency]. + */ + suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz) } From 77dfb7bb05e7d1cdca030a293011872d01b25472 Mon Sep 17 00:00:00 2001 From: Rui Oliveira Date: Fri, 24 Jul 2026 15:50:03 +0100 Subject: [PATCH 5/8] Remove PTT-aware logic, which is not necessary I was setting the Tx frequency only when we were in Tx mode, but the Icom 705 will accept the command to set the Tx frequency even when in Rx mode, so there is no need to check the PTT state before sending the command. --- .../core/data/framework/Ic705Controller.kt | 27 +++------------- .../core/data/framework/IcomCivProtocol.kt | 29 +---------------- .../data/framework/RadioTrackingService.kt | 32 +++++-------------- .../domain/repository/IRadioController.kt | 14 ++------ 4 files changed, 16 insertions(+), 86 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt index 010c115ec..c02d37f36 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt @@ -151,37 +151,18 @@ class Ic705Controller( } override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) { - Log.w(tag, "pttOn: PTT control not used for IC-705 (radio is master)") + Log.w(tag, "pttOn: not used for IC-705") true } override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) { - Log.w(tag, "pttOff: PTT control not used for IC-705 (radio is master)") + Log.w(tag, "pttOff: not used for IC-705") true } // ── IRadioController – IC-705 extended operations ─────────────────────── - /** - * Read the current PTT (TX/RX) status from the radio. - * Returns **true** if the radio is currently transmitting, **false** if - * receiving, **null** on error. - */ - override suspend fun readPttStatus(): Boolean? = withContext(Dispatchers.IO) { - ioMutex.withLock { - val cmd = IcomCivProtocol.buildReadTxStatusCommand() - Log.d(tag, "CMD readPTT → ${IcomCivProtocol.toHex(cmd)}") - val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_TX_STATUS) ?: return@withContext null - IcomCivProtocol.parseTxStatus(payload).also { - Log.d(tag, "PTT status: ${if (it == true) "TX" else if (it == false) "RX" else "unknown"}") - } - } - } - - /** - * Select VFO-A (main) or VFO-B (sub). - * @param vfoA true → VFO-A, false → VFO-B - */ + /** Select VFO-A (main/RX) or VFO-B (sub/TX). */ override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) { val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand() else IcomCivProtocol.buildSelectVfoBCommand() @@ -212,7 +193,7 @@ class Ic705Controller( /** * Set TX VFO frequency via CMD 0x25 sub 0x01 (unselected VFO). - * Used in split mode while PTT is pressed. + * Sent every tracking cycle in split mode alongside [setWorkingFrequency]. */ override suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) { Log.d(tag, "setTxVfoFrequency (0x25/01): ${frequencyHz}Hz") diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt index 0f400c1b6..1b8b807ec 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt @@ -57,9 +57,7 @@ object IcomCivProtocol { const val CMD_CTCSS_TONE: Byte = 0x1B /** Read/write misc settings (used for enabling CTCSS encode). */ const val CMD_MISC_SETTING: Byte = 0x16 - /** Read/write TX-inhibit / transmit status. */ - const val CMD_TX_STATUS: Byte = 0x1C - /** Read/write selected-VFO frequency (cmd 0x25, sub 0x00). */ + /** Read/write selected-VFO frequency (cmd 0x25). */ const val CMD_SELECTED_VFO_FREQ: Byte = 0x25 // ── Sub-command bytes ────────────────────────────────────────────────── @@ -71,8 +69,6 @@ object IcomCivProtocol { const val SUB_SPLIT_OFF: Byte = 0x00 /** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */ const val SUB_SPLIT_ON: Byte = 0x01 - /** Sub for CMD_TX_STATUS: query TX/RX state. */ - const val SUB_TX_STATE: Byte = 0x00 /** Sub for CMD_SELECTED_VFO_FREQ: selected (active) VFO frequency. */ const val SUB_SELECTED_VFO: Byte = 0x00 /** Sub for CMD_SELECTED_VFO_FREQ: unselected (inactive / TX in split) VFO frequency. */ @@ -200,9 +196,6 @@ object IcomCivProtocol { return frame(CMD_DUPLEX_SPLIT, sub) } - /** Query TX/RX status (CMD 0x1C sub 0x00). Radio replies with 0x00=RX, 0x01=TX. */ - fun buildReadTxStatusCommand(): ByteArray = frame(CMD_TX_STATUS, SUB_TX_STATE) - /** * Enable/disable CTCSS encode (CMD 0x16 sub 0x42). * 0x01 = CTCSS encoder ON, 0x00 = OFF. @@ -293,26 +286,6 @@ object IcomCivProtocol { return freqHz to mode } - /** - * Parse TX status from a CMD_TX_STATUS reply payload. - * - * The IC-705 responds to a `1C 00` query with: - * FE FE E0 A4 1C 00 FD - * After [parseResponse] strips the frame header the payload is: - * payload[0] = 0x00 (sub-command echo) - * payload[1] = status byte (0x00 = RX, 0x01 = TX) - * - * Returns true if transmitting, false if receiving, null on error. - */ - fun parseTxStatus(payload: ByteArray): Boolean? { - if (payload.size < 2) return null - return when (payload[1]) { - 0x00.toByte() -> false // RX - 0x01.toByte() -> true // TX - else -> null - } - } - /** Hex dump of bytes, useful for debug logging. */ fun toHex(bytes: ByteArray): String = bytes.joinToString(" ") { String.format(Locale.US, "%02X", it.toInt() and 0xFF) } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt index 67f8e7701..fa43c7dd7 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt @@ -380,33 +380,17 @@ class RadioTrackingService( val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) } if (radio.isConnected) { - // Query PTT state — don't assume silence; filter broadcast noise - val isPtt = radio.readPttStatus() - Log.d(tag, "Split loop: isPTT=$isPtt txFreq=$txRadioFreq rxFreq=$rxRadioFreq") - - when (isPtt) { - true -> { - // PTT ON → radio's active VFO is VFO-B (TX) - // Use 0x25/01 (unselected VFO) to target VFO-B directly - if (txRadioFreq != null) { - Log.d(tag, "Split loop TX update (0x25/01): ${txRadioFreq}Hz") - radio.setTxVfoFrequency(txRadioFreq) - } - } - false -> { - // PTT OFF → radio's active VFO is VFO-A (RX) - // Use 0x25/00 (selected/active VFO) to update RX - if (rxRadioFreq != null) { - Log.d(tag, "Split loop RX update (0x25/00): ${rxRadioFreq}Hz") - radio.setWorkingFrequency(rxRadioFreq) - } + // Update both VFOs every cycle — no PTT polling needed. + // 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO. + if (rxRadioFreq != null) { + Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz") + radio.setWorkingFrequency(rxRadioFreq) } - null -> { - // Could not read PTT (broadcast noise race) — skip this tick - Log.d(tag, "Split loop: PTT read failed, skipping tick") + if (txRadioFreq != null) { + Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz") + radio.setTxVfoFrequency(txRadioFreq) } } - } _state.update { it.copy( diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt index 89d2c83ff..cd198f4b4 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt @@ -41,13 +41,6 @@ interface IRadioController { // ── Extended operations (IC-705 / CI-V) ────────────────────────────── - /** - * Read the current PTT status from the radio. - * Returns true if transmitting, false if receiving, null on error. - * Default: not supported. - */ - suspend fun readPttStatus(): Boolean? = null - /** * Select the active VFO. * @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split). @@ -62,15 +55,14 @@ interface IRadioController { suspend fun setSplitMode(enabled: Boolean): Boolean = false /** - * Set the frequency of the currently active VFO (CMD 0x25 sub 0x00). + * Set the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00). * Default: delegates to [setFrequency]. */ suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz) /** - * Set the frequency of the inactive (TX) VFO via CMD 0x25 sub 0x01. - * Used in split mode when PTT is pressed: the radio has made VFO-B - * the active VFO, so sub 0x01 targets it directly. + * Set the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01). + * Sent every tracking cycle alongside [setWorkingFrequency] in split mode. * Default: delegates to [setWorkingFrequency]. */ suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz) From c7089ab314c05c7f32fd4167e2632fa6aa1a25a8 Mon Sep 17 00:00:00 2001 From: Rui Oliveira Date: Fri, 24 Jul 2026 16:40:46 +0100 Subject: [PATCH 6/8] Fix setting the mode (e.g. FM, USB, LSB) --- .../core/data/framework/Ic705Controller.kt | 17 ++++++- .../core/data/framework/IcomCivProtocol.kt | 50 ++++++++++++++++++- .../data/framework/RadioTrackingService.kt | 26 +++++++--- .../domain/repository/IRadioController.kt | 8 ++- 4 files changed, 89 insertions(+), 12 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt index c02d37f36..d5196000d 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt @@ -75,7 +75,12 @@ class Ic705Controller( outputStream = btSocket.outputStream inputStream = btSocket.inputStream isConnected = true - Log.i(tag, "Connected to $deviceAddress") + // Enter VFO mode — frequency/mode commands return FA if the radio + // is in memory-channel mode. Safe to send regardless of current state. + Log.i(tag, "Connected to $deviceAddress — entering VFO mode") + val vfoCmd = IcomCivProtocol.buildEnterVfoModeCommand() + Log.d(tag, "CMD enterVfoMode → ${IcomCivProtocol.toHex(vfoCmd)}") + ioMutex.withLock { sendAndWaitAck(vfoCmd) } true } catch (e: Exception) { Log.e(tag, "Connect error: ${e.message}") @@ -162,6 +167,16 @@ class Ic705Controller( // ── IRadioController – IC-705 extended operations ─────────────────────── + /** Select the band for [frequencyHz] via CMD 0x1A sub 0x00 (band stacking register). */ + override suspend fun setBand(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) { + val cmd = IcomCivProtocol.buildBandSelectCommand(frequencyHz) ?: run { + Log.w(tag, "setBand: no band code for ${frequencyHz}Hz — skipping") + return@withContext false + } + Log.d(tag, "CMD setBand (${frequencyHz}Hz) → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + /** Select VFO-A (main/RX) or VFO-B (sub/TX). */ override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) { val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand() diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt index 1b8b807ec..215236c3d 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt @@ -51,8 +51,16 @@ object IcomCivProtocol { const val CMD_SET_MODE: Byte = 0x06 /** Select VFO / memory. */ const val CMD_SELECT_VFO: Byte = 0x07 + /** + * Select operating mode (VFO vs memory-channel). + * Sub 0x00 = VFO mode. Must be sent after connect if the radio is in + * memory-channel mode — frequency/mode commands return FA until it is. + */ + const val CMD_SELECT_OP_MODE: Byte = 0x08 /** Set repeater duplex / SPLIT. */ const val CMD_DUPLEX_SPLIT: Byte = 0x0F + /** Band stacking register / band select (sub 0x00 = select, data = BCD band number). */ + const val CMD_BAND_SELECT: Byte = 0x1A /** Read/write CTCSS tone frequency. */ const val CMD_CTCSS_TONE: Byte = 0x1B /** Read/write misc settings (used for enabling CTCSS encode). */ @@ -178,10 +186,41 @@ object IcomCivProtocol { /** Read operating frequency (CMD 0x03). */ fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ) - /** Set operating mode (CMD 0x06). Filter byte 0x00 = keep current filter. */ + /** + * Select band via CMD 0x1A sub 0x00. + * Band codes are BCD-numbered: 1=160m, 2=80m, …, 9=10m, 0x10=6m, 0x11=2m, 0x12=70cm, 0x13=23cm. + * Returns null if [frequencyHz] doesn't fall in a known amateur band. + */ + fun buildBandSelectCommand(frequencyHz: Long): ByteArray? { + val code = bandCodeForFrequency(frequencyHz) ?: return null + return frame(CMD_BAND_SELECT, 0x00, code) + } + + /** + * Map a frequency in Hz to the IC-705 band stacking register code. + * Codes are BCD (band number in decimal expressed as hex nibbles). + */ + fun bandCodeForFrequency(frequencyHz: Long): Byte? = when { + frequencyHz in 1_800_000L ..1_999_999L -> 0x01 // 160 m + frequencyHz in 3_500_000L ..3_999_999L -> 0x02 // 80 m + frequencyHz in 7_000_000L ..7_299_999L -> 0x03 // 40 m + frequencyHz in 10_100_000L ..10_149_999L -> 0x04 // 30 m + frequencyHz in 14_000_000L ..14_349_999L -> 0x05 // 20 m + frequencyHz in 18_068_000L ..18_167_999L -> 0x06 // 17 m + frequencyHz in 21_000_000L ..21_449_999L -> 0x07 // 15 m + frequencyHz in 24_890_000L ..24_989_999L -> 0x08 // 12 m + frequencyHz in 28_000_000L ..29_699_999L -> 0x09 // 10 m + frequencyHz in 50_000_000L ..53_999_999L -> 0x10 // 6 m (BCD 10) + frequencyHz in 144_000_000L ..147_999_999L -> 0x11 // 2 m (BCD 11) + frequencyHz in 420_000_000L ..449_999_999L -> 0x12 // 70 cm (BCD 12) + frequencyHz in 1_240_000_000L ..1_299_999_999L -> 0x13 // 23 cm (BCD 13) + else -> null + } + + /** Set operating mode (CMD 0x06). Filter byte is omitted — radio uses its default filter for the mode. */ fun buildSetModeCommand(mode: String): ByteArray? { val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null - return frame(CMD_SET_MODE, modeByte, 0x00) + return frame(CMD_SET_MODE, modeByte) } /** Select VFO-A (CMD 0x07 sub 0x00). */ @@ -190,6 +229,13 @@ object IcomCivProtocol { /** Select VFO-B (CMD 0x07 sub 0x01). */ fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B) + /** + * Enter VFO operating mode (CMD 0x08 sub 0x00). + * Sent after connect — if the radio is in memory-channel mode frequency + * and mode commands return FA until this is issued. + */ + fun buildEnterVfoModeCommand(): ByteArray = frame(CMD_SELECT_OP_MODE, 0x00) + /** Enable or disable SPLIT mode (CMD 0x0F). */ fun buildSplitModeCommand(enable: Boolean): ByteArray { val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt index fa43c7dd7..fffc4507f 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt @@ -47,6 +47,8 @@ class RadioTrackingService( ) : IRadioTrackingService { private val tag = "RadioTracking" + /** Delay between each step of the split-mode init sequence (ms). */ + private val INIT_STEP_DELAY_MS = 200L private val _state = MutableStateFlow(RadioTrackingState()) override val state: StateFlow = _state @@ -316,10 +318,15 @@ class RadioTrackingService( Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode") // ── Initial setup sequence ────────────────────────────────────────── - // 1. Select VFO-A (will be RX VFO), set downlink frequency and mode + // Sequence per IC-705: explicitly select VFO, then band → freq → mode. + // ACK from each command gates the next — no fixed delays needed. + + // VFO-A = RX (downlink) Log.d(tag, "Split init: selecting VFO-A for RX (downlink)") radio.setVfo(vfoA = true) if (rxNominal != null) { + Log.d(tag, "Split init: VFO-A band for ${rxNominal}Hz") + radio.setBand(rxNominal) Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz") radio.setFrequency(rxNominal) } @@ -328,10 +335,12 @@ class RadioTrackingService( radio.setMode(rxMode) } - // 2. Select VFO-B (will be TX VFO), set uplink frequency, mode, and CTCSS + // VFO-B = TX (uplink) Log.d(tag, "Split init: selecting VFO-B for TX (uplink)") radio.setVfo(vfoA = false) if (txBase != null) { + Log.d(tag, "Split init: VFO-B band for ${txBase}Hz") + radio.setBand(txBase) Log.d(tag, "Split init: VFO-B freq=${txBase}Hz") radio.setFrequency(txBase) } @@ -340,19 +349,20 @@ class RadioTrackingService( radio.setMode(txMode) } if (txMode?.uppercase() == "FM") { - _state.value.ctcssTone?.let { tone -> + val tone = _state.value.ctcssTone + if (tone != null) { Log.d(tag, "Split init: CTCSS=${tone}Hz") radio.setCtcssTone(tone) radio.setCtcssMode(true) + } else { + radio.setCtcssMode(false) } } - // 3. Enable SPLIT — radio will now TX on VFO-B while listening on VFO-A - Log.d(tag, "Split init: enabling SPLIT mode") - radio.setSplitMode(enabled = true) - - // Return to VFO-A so the display shows RX frequency + // Enable SPLIT on VFO-A (return display to RX VFO first) + Log.d(tag, "Split init: returning to VFO-A, then enabling SPLIT mode") radio.setVfo(vfoA = true) + radio.setSplitMode(enabled = true) _state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) } Log.i(tag, "IC-705 split init done — entering tracking loop") diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt index cd198f4b4..80a00b49c 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt @@ -41,10 +41,16 @@ interface IRadioController { // ── Extended operations (IC-705 / CI-V) ────────────────────────────── + /** + * Select the band matching [frequencyHz] via the band stacking register. + * Must be called before [setFrequency] and [setMode] when first tracking. + * Default: no-op (Yaesu radios auto-switch band via frequency). + */ + suspend fun setBand(frequencyHz: Long): Boolean = false + /** * Select the active VFO. * @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split). - * Default: no-op. */ suspend fun setVfo(vfoA: Boolean): Boolean = false From 81525b6dd3376f2589d617802ecb7b0c935c0523 Mon Sep 17 00:00:00 2001 From: Rui Oliveira Date: Fri, 24 Jul 2026 19:58:51 +0100 Subject: [PATCH 7/8] Map AFSK (e.g. ISS APRS) to FM --- .../rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt index 215236c3d..e20c0ed7a 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt @@ -96,7 +96,8 @@ object IcomCivProtocol { "WFM" to 0x06, "CW-R" to 0x07, "RTTY-R" to 0x08, - "DV" to 0x12 + "DV" to 0x12, + "AFSK" to 0x05 // AFSK uses FM modulation ) val BYTE_TO_MODE: Map = MODE_TO_BYTE.entries.associate { it.value to it.key } From 07504a2a950e17c10382d254579f3fe0d9e33a2c Mon Sep 17 00:00:00 2001 From: Rui Oliveira Date: Fri, 24 Jul 2026 20:29:51 +0100 Subject: [PATCH 8/8] Add the option to override the frequency from the radio --- .../core/data/framework/Ic705Controller.kt | 44 +++++++++ .../core/data/framework/IcomCivProtocol.kt | 6 ++ .../data/framework/RadioTrackingService.kt | 90 +++++++++++++++---- .../domain/repository/IRadioController.kt | 13 +++ 4 files changed, 137 insertions(+), 16 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt index d5196000d..3a8905833 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt @@ -217,6 +217,50 @@ class Ic705Controller( ioMutex.withLock { sendAndWaitAck(cmd) } } + /** + * Read the frequency of the currently active VFO (CMD 0x25 sub 0x00). + * Used for tuning detection in split mode. + */ + override suspend fun readWorkingFrequency(): Long? = withContext(Dispatchers.IO) { + ioMutex.withLock { + val cmd = IcomCivProtocol.buildReadWorkingFreqCommand() + Log.d(tag, "CMD readWorkingFreq → ${IcomCivProtocol.toHex(cmd)}") + val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null + // Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse + Log.d(tag, "readWorkingFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}") + if (payload.size < 6) { + Log.w(tag, "readWorkingFreq: payload too short (${payload.size} bytes)") + return@withContext null + } + val freqBcd = payload.sliceArray(1..5) + val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd) + Log.d(tag, "readWorkingFreq: ${freq}Hz") + freq + } + } + + /** + * Read the frequency of the inactive/TX VFO (CMD 0x25 sub 0x01). + * Used for tuning detection in split mode. + */ + override suspend fun readTxVfoFrequency(): Long? = withContext(Dispatchers.IO) { + ioMutex.withLock { + val cmd = IcomCivProtocol.buildReadTxVfoFreqCommand() + Log.d(tag, "CMD readTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}") + val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null + // Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse + Log.d(tag, "readTxVfoFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}") + if (payload.size < 6) { + Log.w(tag, "readTxVfoFreq: payload too short (${payload.size} bytes)") + return@withContext null + } + val freqBcd = payload.sliceArray(1..5) + val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd) + Log.d(tag, "readTxVfoFreq: ${freq}Hz") + freq + } + } + // ── Internal I/O helpers ──────────────────────────────────────────────── /** diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt index e20c0ed7a..cc281ed54 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt @@ -187,6 +187,12 @@ object IcomCivProtocol { /** Read operating frequency (CMD 0x03). */ fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ) + /** Read selected (active) VFO frequency (CMD 0x25 sub 0x00). */ + fun buildReadWorkingFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO) + + /** Read unselected (inactive/TX in split) VFO frequency (CMD 0x25 sub 0x01). */ + fun buildReadTxVfoFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO) + /** * Select band via CMD 0x1A sub 0x00. * Band codes are BCD-numbered: 1=160m, 2=80m, …, 9=10m, 0x10=6m, 0x11=2m, 0x12=70cm, 0x13=23cm. diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt index fffc4507f..e5a83f7af 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt @@ -367,21 +367,77 @@ class RadioTrackingService( _state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) } Log.i(tag, "IC-705 split init done — entering tracking loop") - // ── Tracking loop ─────────────────────────────────────────────────── - // After initial setup we never change VFO or band. - // We only update the active-VFO frequency using CMD 0x25 sub 0x00. - // The IC-705 automatically makes VFO-B active while PTT is pressed. + // ── Tracking loop with tuning detection ───────────────────────────── + var lastSetTxFreq = 0.0 + var lastSetRxFreq = 0.0 + var tuningRadio = "" // "tx" or "rx" when manual tuning detected + var lastReadFreq = 0L + var stableCount = 0 + while (currentCoroutineContext().isActive) { val currentState = _state.value if (!currentState.isActive) break val satPass = currentState.currentPass ?: break val xpdr = currentState.selectedTransponder ?: break - val txBaseFreq = currentState.txBaseFrequencyHz + var txBaseFreq = currentState.txBaseFrequencyHz val stationPos = settingsRepo.stationPosition.value val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis()) val v = pos.distanceRate * 1000.0 + if (tuningRadio.isNotEmpty()) { + // User is tuning — wait for frequency to stabilize + val readFreq = if (tuningRadio == "tx") radio.readTxVfoFrequency() else radio.readWorkingFrequency() + if (readFreq != null) { + if (kotlin.math.abs(readFreq - lastReadFreq) <= 20) stableCount++ + else { stableCount = 0; lastReadFreq = readFreq } + + if (stableCount >= 2) { + // Frequency stable — reverse-calculate base frequency + if (tuningRadio == "tx" && txBaseFreq != null) { + val newBase = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong() + if (newBase > 0) { + txBaseFreq = newBase + _state.update { it.copy(txBaseFrequencyHz = newBase) } + Log.i(tag, "Split TX tuning done → base=$newBase") + } + } else if (tuningRadio == "rx") { + val rxNominal = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong() + val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr) + if (newTxBase != null && newTxBase > 0) { + txBaseFreq = newTxBase + _state.update { it.copy(txBaseFrequencyHz = newTxBase) } + Log.i(tag, "Split RX tuning done → txBase=$newTxBase") + } + } + tuningRadio = "" + stableCount = 0 + lastSetTxFreq = 0.0 + lastSetRxFreq = 0.0 + } + } + } else { + // Detect manual dial changes + if (txBaseFreq != null && lastSetTxFreq > 0.0) { + val readTx = radio.readTxVfoFrequency() + if (readTx != null && kotlin.math.abs(readTx - lastSetTxFreq) >= 20.0) { + tuningRadio = "tx" + lastReadFreq = readTx + stableCount = 0 + Log.i(tag, "Split TX tuning detected (read=${readTx}, lastSet=$lastSetTxFreq)") + } + } + if (tuningRadio.isEmpty() && lastSetRxFreq > 0.0) { + val readRx = radio.readWorkingFrequency() + if (readRx != null && kotlin.math.abs(readRx - lastSetRxFreq) >= 20.0) { + tuningRadio = "rx" + lastReadFreq = readRx + stableCount = 0 + Log.i(tag, "Split RX tuning detected (read=${readRx}, lastSet=$lastSetRxFreq)") + } + } + } + // Determine Doppler-corrected frequencies val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) } val rxBaseCalc = if (txBaseFreq != null) { @@ -389,18 +445,20 @@ class RadioTrackingService( } else xpdr.downlinkLow val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) } - if (radio.isConnected) { - // Update both VFOs every cycle — no PTT polling needed. - // 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO. - if (rxRadioFreq != null) { - Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz") - radio.setWorkingFrequency(rxRadioFreq) - } - if (txRadioFreq != null) { - Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz") - radio.setTxVfoFrequency(txRadioFreq) - } + if (radio.isConnected && tuningRadio.isEmpty()) { + // Update both VFOs every cycle — no PTT polling needed. + // 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO. + if (rxRadioFreq != null) { + Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz") + radio.setWorkingFrequency(rxRadioFreq) + lastSetRxFreq = rxRadioFreq.toDouble() } + if (txRadioFreq != null) { + Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz") + radio.setTxVfoFrequency(txRadioFreq) + lastSetTxFreq = txRadioFreq.toDouble() + } + } _state.update { it.copy( diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt index 80a00b49c..74bb08bd9 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt @@ -72,4 +72,17 @@ interface IRadioController { * Default: delegates to [setWorkingFrequency]. */ suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz) + + /** + * Read the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00). + * Default: delegates to [readFrequencyAndMode]. + */ + suspend fun readWorkingFrequency(): Long? = readFrequencyAndMode()?.first + + /** + * Read the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01). + * Used for tuning detection in split mode. + * Default: delegates to [readWorkingFrequency]. + */ + suspend fun readTxVfoFrequency(): Long? = readWorkingFrequency() }