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..3a8905833 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt @@ -0,0 +1,364 @@ +/* + * 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 + // 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}") + 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: not used for IC-705") + true + } + + override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) { + Log.w(tag, "pttOff: not used for IC-705") + true + } + + // ── 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() + 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 (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). + * 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") + val cmd = IcomCivProtocol.buildSetUnselectedVfoFreqCommand(frequencyHz) + Log.d(tag, "CMD setTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}") + 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 ──────────────────────────────────────────────── + + /** + * 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. + * 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) + 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)) + // Exit early once we have a complete frame for us + if (hasCompleteFrameForUs(result)) break + } + } else { + delay(POLL_INTERVAL_MS) + } + } catch (e: Exception) { + Log.e(tag, "Drain error: ${e.message}") + isConnected = false + break + } + } + 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) + 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..cc281ed54 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt @@ -0,0 +1,352 @@ +/* + * 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 + /** + * 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). */ + const val CMD_MISC_SETTING: Byte = 0x16 + /** Read/write selected-VFO frequency (cmd 0x25). */ + 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_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() + + // ── 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, + "AFSK" to 0x05 // AFSK uses FM modulation + ) + + 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)) + } + + /** + * 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) + + /** 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. + * 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) + } + + /** 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) + + /** + * 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 + return frame(CMD_DUPLEX_SPLIT, sub) + } + + /** + * 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 + } + + /** 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..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 @@ -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 @@ -29,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 @@ -44,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 @@ -51,227 +56,427 @@ 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 (currentCoroutineContext().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 + + val txBase = initialTxBaseFreqHz ?: txCenter + Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode") + + // ── Initial setup sequence ────────────────────────────────────────── + // 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) + } + if (rxMode != null) { + Log.d(tag, "Split init: VFO-A mode=$rxMode") + radio.setMode(rxMode) + } + + // 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) + } + if (txMode != null) { + Log.d(tag, "Split init: VFO-B mode=$txMode") + radio.setMode(txMode) + } + if (txMode?.uppercase() == "FM") { + 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) + } + } + + // 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) - // Command radios (only when not tuning) - if (tuningRadio.isEmpty()) { - if (tx != null && tx.isConnected && txRadioFreq != null) { - tx.setFrequency(txRadioFreq) - lastSetTxFreq = txRadioFreq.toDouble() + _state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) } + Log.i(tag, "IC-705 split init done — entering tracking loop") + + // ── 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 + 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 } - if (rx != null && rx.isConnected && rxRadioFreq != null) { - rx.setFrequency(rxRadioFreq) - lastSetRxFreq = rxRadioFreq.toDouble() + } + } 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) { + TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr) + } else xpdr.downlinkLow + val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) } - _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 - ) + 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() + } + } - 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 +493,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 +506,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 +553,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..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 @@ -38,4 +38,51 @@ interface IRadioController { suspend fun pttOn(): Boolean suspend fun pttOff(): Boolean + + // ── 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). + */ + 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 (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 (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) + + /** + * 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() } 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..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 @@ -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,114 +442,164 @@ 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 pairedDevices = remember { - try { - val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList() - } catch (_: SecurityException) { - emptyList() + 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: 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) { } } } 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 +608,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 +627,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 + ) } } }