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
+ )
}
}
}