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 00000000..860df4d5 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/Ic705Controller.kt @@ -0,0 +1,281 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.data.framework + +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothSocket +import android.util.Log +import com.rtbishop.look4sat.core.domain.repository.IRadioController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.io.OutputStream +import java.util.UUID + +/** + * Icom IC-705 CI-V controller over Bluetooth SPP. + * + * The IC-705 emits broadcast frames continuously (band scope, UTC, signal + * level, …). A reply to any command we send may therefore be buried in + * that noise. All response reads drain up to [ACK_TIMEOUT_MS] and scan the + * entire accumulated buffer for the frame we expect rather than assuming + * the very next byte is the response. + */ +class Ic705Controller( + private val bluetoothManager: BluetoothManager, + private val deviceAddress: String +) : IRadioController { + + private val tag = "IC705" + private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb") + private val ioMutex = Mutex() + + /** Time budget (ms) to wait for a response amid broadcast noise. */ + private val ACK_TIMEOUT_MS = 500L + /** Polling interval while draining the input buffer. */ + private val POLL_INTERVAL_MS = 20L + /** Small pause after writing a command before reading the response. */ + private val WRITE_SETTLE_MS = 50L + + private var socket: BluetoothSocket? = null + private var outputStream: OutputStream? = null + private var inputStream: InputStream? = null + + override var isConnected: Boolean = false + private set + + // ── Connection ────────────────────────────────────────────────────────── + + override suspend fun connect(): Boolean = withContext(Dispatchers.IO) { + if (isConnected) return@withContext true + if (deviceAddress.isBlank()) return@withContext false + try { + val device = bluetoothManager.adapter.getRemoteDevice(deviceAddress) + val btSocket = device.createInsecureRfcommSocketToServiceRecord(sppId) + btSocket.connect() + socket = btSocket + outputStream = btSocket.outputStream + inputStream = btSocket.inputStream + isConnected = true + Log.i(tag, "Connected to $deviceAddress") + true + } catch (e: Exception) { + Log.e(tag, "Connect error: ${e.message}") + isConnected = false + false + } + } + + override suspend fun disconnect() = withContext(Dispatchers.IO) { + try { + inputStream?.close() + outputStream?.close() + socket?.close() + } catch (e: Exception) { + Log.e(tag, "Disconnect error: ${e.message}") + } finally { + inputStream = null + outputStream = null + socket = null + isConnected = false + Log.i(tag, "Disconnected from $deviceAddress") + } + } + + // ── IRadioController – standard operations ────────────────────────────── + + override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) { + Log.d(tag, "setFrequency: ${frequencyHz}Hz") + ioMutex.withLock { + val cmd = IcomCivProtocol.buildSetFreqCommand(frequencyHz) + Log.d(tag, "CMD setFreq → ${IcomCivProtocol.toHex(cmd)}") + sendAndWaitAck(cmd) + } + } + + override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) { + val cmd = IcomCivProtocol.buildSetModeCommand(mode) ?: run { + Log.w(tag, "setMode: unknown mode '$mode'") + return@withContext false + } + Log.d(tag, "setMode: $mode") + Log.d(tag, "CMD setMode → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) { + Log.d(tag, "setCtcssMode: $enabled") + val cmd = IcomCivProtocol.buildCtcssModeCommand(enabled) + Log.d(tag, "CMD ctcssMode → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) { + Log.d(tag, "setCtcssTone: ${toneHz}Hz") + val cmd = IcomCivProtocol.buildSetCtcssToneCommand(toneHz) + Log.d(tag, "CMD ctcssTone → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + override suspend fun readFrequencyAndMode(): Pair? = withContext(Dispatchers.IO) { + ioMutex.withLock { + val cmd = IcomCivProtocol.buildReadFreqCommand() + Log.d(tag, "CMD readFreq → ${IcomCivProtocol.toHex(cmd)}") + val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_READ_FREQ) ?: return@withContext null + // Read-freq reply payload: [cmd byte already stripped by parseResponse] [5 freq bytes] [mode] [filter] + IcomCivProtocol.parseFreqModePayload(payload).also { + if (it != null) Log.d(tag, "readFreqMode: ${it.first}Hz, ${it.second}") + else Log.w(tag, "readFreqMode: parse failed, payload=${IcomCivProtocol.toHex(payload)}") + } + } + } + + override suspend fun pttOn(): Boolean = withContext(Dispatchers.IO) { + Log.w(tag, "pttOn: PTT control not used for IC-705 (radio is master)") + true + } + + override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) { + Log.w(tag, "pttOff: PTT control not used for IC-705 (radio is master)") + true + } + + // ── IRadioController – IC-705 extended operations ─────────────────────── + + /** + * Read the current PTT (TX/RX) status from the radio. + * Returns **true** if the radio is currently transmitting, **false** if + * receiving, **null** on error. + */ + override suspend fun readPttStatus(): Boolean? = withContext(Dispatchers.IO) { + ioMutex.withLock { + val cmd = IcomCivProtocol.buildReadTxStatusCommand() + Log.d(tag, "CMD readPTT → ${IcomCivProtocol.toHex(cmd)}") + val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_TX_STATUS) ?: return@withContext null + IcomCivProtocol.parseTxStatus(payload).also { + Log.d(tag, "PTT status: ${if (it == true) "TX" else if (it == false) "RX" else "unknown"}") + } + } + } + + /** + * Select VFO-A (main) or VFO-B (sub). + * @param vfoA true → VFO-A, false → VFO-B + */ + override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) { + val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand() + else IcomCivProtocol.buildSelectVfoBCommand() + Log.d(tag, "CMD selectVFO${if (vfoA) "A" else "B"} → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + /** + * Enable or disable SPLIT mode (TX on sub-VFO while listening on main VFO). + */ + override suspend fun setSplitMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) { + val cmd = IcomCivProtocol.buildSplitModeCommand(enabled) + Log.d(tag, "CMD split ${if (enabled) "ON" else "OFF"} → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + /** + * Set the frequency of the **currently active** VFO (CMD 0x25 sub 0x00). + * In split mode the radio automatically switches active VFO on PTT, so + * always writing to the active VFO is the correct strategy. + */ + override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) { + Log.d(tag, "setWorkingFrequency: ${frequencyHz}Hz") + val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz) + Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}") + ioMutex.withLock { sendAndWaitAck(cmd) } + } + + // ── Internal I/O helpers ──────────────────────────────────────────────── + + /** + * Write [cmd] to the radio and drain the input stream for up to + * [ACK_TIMEOUT_MS], looking for an OK/NG acknowledgement frame. + */ + private suspend fun sendAndWaitAck(cmd: ByteArray): Boolean { + if (!write(cmd)) return false + delay(WRITE_SETTLE_MS) + val buf = drainWithTimeout(ACK_TIMEOUT_MS) + val ok = IcomCivProtocol.containsAck(buf) + if (!ok) Log.w(tag, "ACK not found in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}") + return ok + } + + /** + * Write [cmd] to the radio and drain the input stream for up to + * [ACK_TIMEOUT_MS], scanning for a response frame carrying [expectCmd]. + * Returns the payload bytes of that frame, or null on timeout/error. + */ + private suspend fun sendAndReadResponse(cmd: ByteArray, expectCmd: Byte): ByteArray? { + if (!write(cmd)) return null + delay(WRITE_SETTLE_MS) + val buf = drainWithTimeout(ACK_TIMEOUT_MS) + val response = IcomCivProtocol.parseResponse(buf, expectCmd) + if (response == null) { + Log.w(tag, "No response for cmd 0x${String.format("%02X", expectCmd.toInt() and 0xFF)} " + + "in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}") + } + return response?.payload + } + + /** + * Drain whatever bytes the radio has buffered within a [timeoutMs] window. + * Uses non-blocking `available()` polling to avoid blocking indefinitely. + */ + private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray { + val result = mutableListOf() + val deadline = System.currentTimeMillis() + timeoutMs + val stream = inputStream ?: return ByteArray(0) + while (System.currentTimeMillis() < deadline) { + try { + val available = stream.available() + if (available > 0) { + val chunk = ByteArray(available) + val read = stream.read(chunk) + if (read > 0) result.addAll(chunk.take(read)) + } else { + delay(POLL_INTERVAL_MS) + } + } catch (e: Exception) { + Log.e(tag, "Drain error: ${e.message}") + isConnected = false + break + } + } + return result.toByteArray() + } + + private fun write(bytes: ByteArray): Boolean { + return try { + outputStream?.write(bytes) + outputStream?.flush() + true + } catch (e: Exception) { + Log.e(tag, "Write error: ${e.message}") + isConnected = false + false + } + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt new file mode 100644 index 00000000..a851e3f5 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/IcomCivProtocol.kt @@ -0,0 +1,307 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.data.framework + +import java.util.Locale + +/** + * Icom CI-V protocol encoder/decoder for the IC-705. + * + * Frame structure: + * FE FE [] [] FD + * + * IC-705 default CI-V address : 0xA4 + * Controller (us) address : 0xE0 + */ +object IcomCivProtocol { + + // ── Framing constants ────────────────────────────────────────────────── + const val PREAMBLE: Byte = 0xFE.toByte() + const val END_OF_MSG: Byte = 0xFD.toByte() + const val ACK_OK: Byte = 0xFB.toByte() + const val ACK_NG: Byte = 0xFA.toByte() + + // ── Address constants ────────────────────────────────────────────────── + /** Default CI-V address of the IC-705. */ + const val ADDR_IC705: Byte = 0xA4.toByte() + /** Default CI-V address of the controller (us). */ + const val ADDR_CTRL: Byte = 0xE0.toByte() + + // ── Command bytes ────────────────────────────────────────────────────── + /** Read operating frequency (main VFO). */ + const val CMD_READ_FREQ: Byte = 0x03 + /** Set operating frequency (main VFO). */ + const val CMD_SET_FREQ: Byte = 0x05 + /** Set operating mode. */ + const val CMD_SET_MODE: Byte = 0x06 + /** Select VFO / memory. */ + const val CMD_SELECT_VFO: Byte = 0x07 + /** Set repeater duplex / SPLIT. */ + const val CMD_DUPLEX_SPLIT: Byte = 0x0F + /** Read/write CTCSS tone frequency. */ + const val CMD_CTCSS_TONE: Byte = 0x1B + /** Read/write misc settings (used for enabling CTCSS encode). */ + const val CMD_MISC_SETTING: Byte = 0x16 + /** Read/write TX-inhibit / transmit status. */ + const val CMD_TX_STATUS: Byte = 0x1C + /** Read/write selected-VFO frequency (cmd 0x25, sub 0x00). */ + const val CMD_SELECTED_VFO_FREQ: Byte = 0x25 + + // ── Sub-command bytes ────────────────────────────────────────────────── + /** Sub for CMD_SELECT_VFO: select VFO-A (main). */ + const val SUB_VFO_A: Byte = 0x00 + /** Sub for CMD_SELECT_VFO: select VFO-B (sub). */ + const val SUB_VFO_B: Byte = 0x01 + /** Sub for CMD_DUPLEX_SPLIT: simplex / split OFF. */ + const val SUB_SPLIT_OFF: Byte = 0x00 + /** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */ + const val SUB_SPLIT_ON: Byte = 0x01 + /** Sub for CMD_TX_STATUS: query TX/RX state. */ + const val SUB_TX_STATE: Byte = 0x00 + /** Sub for CMD_SELECTED_VFO_FREQ: selected VFO frequency. */ + const val SUB_SELECTED_VFO: Byte = 0x00 + /** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */ + const val SUB_CTCSS_SETTING: Byte = 0x42.toByte() + + // ── Mode bytes ──────────────────────────────────────────────────────── + /** Maps mode strings (upper-case) → IC-705 mode bytes. */ + val MODE_TO_BYTE: Map = mapOf( + "LSB" to 0x00, + "USB" to 0x01, + "AM" to 0x02, + "CW" to 0x03, + "RTTY" to 0x04, + "FM" to 0x05, + "WFM" to 0x06, + "CW-R" to 0x07, + "RTTY-R" to 0x08, + "DV" to 0x12 + ) + + val BYTE_TO_MODE: Map = MODE_TO_BYTE.entries.associate { it.value to it.key } + + // ── Frequency BCD encoding ───────────────────────────────────────────── + + /** + * Encode a frequency in Hz to the IC-705's 5-byte BCD format. + * + * The IC-705 uses 5 bytes, LSB pair first, with 1 Hz resolution. + * Example: 145,500,000 Hz → "0145500000" → pairs LSB→MSB: + * [00, 00, 50, 45, 01] + */ + fun encodeFrequencyBcd(frequencyHz: Long): ByteArray { + val digits = String.format(Locale.US, "%010d", frequencyHz) + val bcd = ByteArray(5) + for (i in 0 until 5) { + // digits are MSB first; we want pair index 0 = LSB pair + val pairIndex = 4 - i + val high = digits[pairIndex * 2] - '0' + val low = digits[pairIndex * 2 + 1] - '0' + bcd[i] = ((high shl 4) or low).toByte() + } + return bcd + } + + /** + * Decode 5-byte BCD frequency (LSB pair first) to Hz. + */ + fun decodeFrequencyBcd(bcd: ByteArray): Long { + // Build digit string MSB→LSB by reversing the byte order + var freqHz = 0L + for (i in 4 downTo 0) { + val b = bcd[i].toInt() and 0xFF + val high = b shr 4 + val low = b and 0x0F + freqHz = freqHz * 100 + high * 10 + low + } + return freqHz + } + + /** + * Encode a CTCSS tone (Hz, e.g. 67.0) to 2-byte BCD (0.1 Hz resolution). + * 67.0 → 670 (tenths of Hz) → BCD bytes [0x06, 0x70]. + */ + fun encodeCtcssToneBcd(toneHz: Double): ByteArray { + val tone01 = (toneHz * 10).toLong() + val digits = String.format(Locale.US, "%04d", tone01) + return byteArrayOf( + ((digits[0] - '0') shl 4 or (digits[1] - '0')).toByte(), + ((digits[2] - '0') shl 4 or (digits[3] - '0')).toByte() + ) + } + + // ── Message builders ─────────────────────────────────────────────────── + + /** Wrap payload bytes in a CI-V frame: FE FE DEST SRC ... FD. */ + private fun frame(vararg payload: Byte): ByteArray { + return byteArrayOf(PREAMBLE, PREAMBLE, ADDR_IC705, ADDR_CTRL) + + payload + + byteArrayOf(END_OF_MSG) + } + + /** Set operating frequency via CMD 0x05 (main VFO). */ + fun buildSetFreqCommand(frequencyHz: Long): ByteArray { + return frame(CMD_SET_FREQ, *encodeFrequencyBcd(frequencyHz)) + } + + /** + * Set selected-VFO frequency via CMD 0x25 sub 0x00. + * This updates whichever VFO is currently active (RX or TX after split). + */ + fun buildSetWorkingFreqCommand(frequencyHz: Long): ByteArray { + return frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO, *encodeFrequencyBcd(frequencyHz)) + } + + /** Read operating frequency (CMD 0x03). */ + fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ) + + /** Set operating mode (CMD 0x06). Filter byte 0x00 = keep current filter. */ + fun buildSetModeCommand(mode: String): ByteArray? { + val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null + return frame(CMD_SET_MODE, modeByte, 0x00) + } + + /** Select VFO-A (CMD 0x07 sub 0x00). */ + fun buildSelectVfoACommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_A) + + /** Select VFO-B (CMD 0x07 sub 0x01). */ + fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B) + + /** Enable or disable SPLIT mode (CMD 0x0F). */ + fun buildSplitModeCommand(enable: Boolean): ByteArray { + val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF + return frame(CMD_DUPLEX_SPLIT, sub) + } + + /** Query TX/RX status (CMD 0x1C sub 0x00). Radio replies with 0x00=RX, 0x01=TX. */ + fun buildReadTxStatusCommand(): ByteArray = frame(CMD_TX_STATUS, SUB_TX_STATE) + + /** + * Enable/disable CTCSS encode (CMD 0x16 sub 0x42). + * 0x01 = CTCSS encoder ON, 0x00 = OFF. + */ + fun buildCtcssModeCommand(enabled: Boolean): ByteArray { + val value: Byte = if (enabled) 0x01 else 0x00 + return frame(CMD_MISC_SETTING, SUB_CTCSS_SETTING, value) + } + + /** + * Set CTCSS tone frequency (CMD 0x1B sub 0x00). + */ + fun buildSetCtcssToneCommand(toneHz: Double): ByteArray { + val bcd = encodeCtcssToneBcd(toneHz) + return frame(CMD_CTCSS_TONE, 0x00, *bcd) + } + + // ── Response parsing ─────────────────────────────────────────────────── + + /** + * Find and parse a complete CI-V response frame from a buffer. + * + * Returns the bytes between "FE FE E0 A4 " and FD, or null if no + * complete frame was found. The search is tolerant of interleaved + * broadcast traffic. + * + * @param buf bytes accumulated from the radio + * @param expectCmd the command byte we are looking for in the reply, or + * null to accept any command response from the radio + */ + fun parseResponse(buf: ByteArray, expectCmd: Byte?): ParsedResponse? { + var i = 0 + while (i < buf.size - 5) { + // Look for FE FE preamble + if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue } + val dest = buf[i + 2] + val src = buf[i + 3] + val cmd = buf[i + 4] + // We only care about frames addressed to us from the radio + if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue } + // Find the terminating FD + val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5) + if (fdIdx < 0) break // incomplete frame, wait for more data + val payload = buf.copyOfRange(i + 5, fdIdx) + if (expectCmd == null || cmd == expectCmd) { + return ParsedResponse(cmd, payload, fdIdx + 1) + } + i = fdIdx + 1 + } + return null + } + + private fun ByteArray.indexOf(b: Byte, startIndex: Int): Int { + for (k in startIndex until size) if (this[k] == b) return k + return -1 + } + + /** + * Check whether a buffer contains an OK acknowledgement (FB FD) from + * the radio. Tolerates broadcast noise before the ACK. + */ + fun containsAck(buf: ByteArray): Boolean { + var i = 0 + while (i < buf.size - 5) { + if (buf[i] != PREAMBLE || buf[i + 1] != PREAMBLE) { i++; continue } + val dest = buf[i + 2] + val src = buf[i + 3] + val cmd = buf[i + 4] + if (dest != ADDR_CTRL || src != ADDR_IC705) { i++; continue } + // Skip to FD + val fdIdx = buf.indexOf(END_OF_MSG, startIndex = i + 5) + if (fdIdx < 0) break + if (cmd == ACK_OK) return true + if (cmd == ACK_NG) return false + i = fdIdx + 1 + } + return false + } + + /** + * Parse frequency + mode from a CMD_READ_FREQ reply payload. + * Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte] + */ + fun parseFreqModePayload(payload: ByteArray): Pair? { + if (payload.size < 6) return null + val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5)) + val mode = BYTE_TO_MODE[payload[5]] ?: return null + return freqHz to mode + } + + /** + * Parse TX status from a CMD_TX_STATUS reply payload. + * Returns true if transmitting, false if receiving, null on error. + */ + fun parseTxStatus(payload: ByteArray): Boolean? { + if (payload.isEmpty()) return null + return when (payload[0]) { + 0x00.toByte() -> false // RX + 0x01.toByte() -> true // TX + else -> null + } + } + + /** Hex dump of bytes, useful for debug logging. */ + fun toHex(bytes: ByteArray): String = + bytes.joinToString(" ") { String.format(Locale.US, "%02X", it.toInt() and 0xFF) } + + data class ParsedResponse( + val cmd: Byte, + val payload: ByteArray, + /** Index in the source buffer immediately after the FD terminator. */ + val nextOffset: Int + ) +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt index c3f9c156..37d05d47 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/framework/RadioTrackingService.kt @@ -19,8 +19,10 @@ package com.rtbishop.look4sat.core.data.framework import android.bluetooth.BluetoothManager import android.util.Log +import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.predict.OrbitalPass +import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT import com.rtbishop.look4sat.core.domain.repository.IRadioController import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo @@ -51,227 +53,377 @@ class RadioTrackingService( private var rxController: IRadioController? = null private var trackingJob: Job? = null + // ── Connection ────────────────────────────────────────────────────────── + override suspend fun connectRadios() { - // Disconnect old controllers if any txController?.disconnect() rxController?.disconnect() - // Read current addresses from settings val rcSettings = settingsRepo.radioControlSettings.value - val txAddr = rcSettings.txRadioAddress - val rxAddr = rcSettings.rxRadioAddress + val txAddr = rcSettings.txRadioAddress + val rxAddr = rcSettings.rxRadioAddress + val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705 + val isSplit = isIcom && rcSettings.splitMode - Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr") + Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr") - if (txAddr.isBlank() && rxAddr.isBlank()) { - _state.update { it.copy(errorMessage = "No radio addresses configured in Settings") } - return - } - - val tx = Ft817Controller(bluetoothManager, txAddr) - val rx = Ft817Controller(bluetoothManager, 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 - } - ) + 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") - } - // Set CTCSS if FM - if (txMode?.uppercase() == "FM") { - _state.value.ctcssTone?.let { tone -> - 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 + val rcSettings = settingsRepo.radioControlSettings.value + val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705 + val isSplit = isIcom && rcSettings.splitMode - 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") - } - } - 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)") - } - } - } - - // 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)") - } - } - } - } - - // Compute Doppler-corrected frequencies - 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) } - - // Command radios (only when not tuning) - if (tuningRadio.isEmpty()) { - if (tx != null && tx.isConnected && txRadioFreq != null) { - tx.setFrequency(txRadioFreq) - lastSetTxFreq = txRadioFreq.toDouble() - } - if (rx != null && rx.isConnected && rxRadioFreq != null) { - rx.setFrequency(rxRadioFreq) - lastSetRxFreq = rxRadioFreq.toDouble() - } - } - - _state.update { - it.copy( - txConnected = tx?.isConnected ?: false, - rxConnected = rx?.isConnected ?: false, - txFrequencyHz = txRadioFreq, - rxFrequencyHz = rxRadioFreq, - azimuth = Math.toDegrees(pos.azimuth), - elevation = Math.toDegrees(pos.elevation), - distance = pos.distance - ) - } - - delay(1000) - } + 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) + } + + 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 = "" + var lastReadFreq = 0L + var stableCount = 0 + + while (isActive) { + val currentState = _state.value + if (!currentState.isActive) break + + val satPass = currentState.currentPass ?: break + val xpdr = currentState.selectedTransponder ?: break + var txBaseFreq = currentState.txBaseFrequencyHz + val stationPos = settingsRepo.stationPosition.value + val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis()) + val txNow = txController + val rxNow = rxController + val v = pos.distanceRate * 1000.0 + + if (tuningRadio.isNotEmpty()) { + val radio = if (tuningRadio == "tx") txNow else rxNow + if (radio != null && radio.isConnected) { + val read = radio.readFrequencyAndMode() + if (read != null) { + val (freq, _) = read + if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++ + else { stableCount = 0; lastReadFreq = freq } + if (stableCount >= 2) { + if (tuningRadio == "tx" && txBaseFreq != null) { + val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong() + if (newBase > 0) { + txBaseFreq = newBase + _state.update { it.copy(txBaseFrequencyHz = newBase) } + Log.i(tag, "TX tuning done → base=$newBase") + } + } else if (tuningRadio == "rx") { + val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong() + val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr) + if (newTxBase != null && newTxBase > 0) { + txBaseFreq = newTxBase + _state.update { it.copy(txBaseFrequencyHz = newTxBase) } + Log.i(tag, "RX tuning done → txBase=$newTxBase") + } + } + tuningRadio = "" + stableCount = 0 + lastSetTxFreq = 0.0 + lastSetRxFreq = 0.0 + } + } + } + } 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)") + } + } + 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) } + + if (tuningRadio.isEmpty()) { + if (txNow != null && txNow.isConnected && txRadioFreq != null) { + txNow.setFrequency(txRadioFreq) + lastSetTxFreq = txRadioFreq.toDouble() + } + 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 ────────────────────────────────────────── + // 1. Select VFO-A (will be RX VFO), set downlink frequency and mode + Log.d(tag, "Split init: selecting VFO-A for RX (downlink)") + radio.setVfo(vfoA = true) + if (rxNominal != null) { + Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz") + radio.setFrequency(rxNominal) + } + if (rxMode != null) { + Log.d(tag, "Split init: VFO-A mode=$rxMode") + radio.setMode(rxMode) + } + + // 2. Select VFO-B (will be TX VFO), set uplink frequency, mode, and CTCSS + Log.d(tag, "Split init: selecting VFO-B for TX (uplink)") + radio.setVfo(vfoA = false) + if (txBase != null) { + Log.d(tag, "Split init: VFO-B freq=${txBase}Hz") + radio.setFrequency(txBase) + } + if (txMode != null) { + Log.d(tag, "Split init: VFO-B mode=$txMode") + radio.setMode(txMode) + } + if (txMode?.uppercase() == "FM") { + _state.value.ctcssTone?.let { tone -> + Log.d(tag, "Split init: CTCSS=${tone}Hz") + radio.setCtcssTone(tone) + radio.setCtcssMode(true) + } + } + + // 3. Enable SPLIT — radio will now TX on VFO-B while listening on VFO-A + Log.d(tag, "Split init: enabling SPLIT mode") + radio.setSplitMode(enabled = true) + + // Return to VFO-A so the display shows RX frequency + radio.setVfo(vfoA = true) + + _state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) } + Log.i(tag, "IC-705 split init done — entering tracking loop") + + // ── Tracking loop ─────────────────────────────────────────────────── + // After initial setup we never change VFO or band. + // We only update the active-VFO frequency using CMD 0x25 sub 0x00. + // The IC-705 automatically makes VFO-B active while PTT is pressed. + while (isActive) { + val currentState = _state.value + if (!currentState.isActive) break + + val satPass = currentState.currentPass ?: break + val xpdr = currentState.selectedTransponder ?: break + val txBaseFreq = currentState.txBaseFrequencyHz + val stationPos = settingsRepo.stationPosition.value + val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis()) + val v = pos.distanceRate * 1000.0 + + // Determine Doppler-corrected frequencies + val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) } + val rxBaseCalc = if (txBaseFreq != null) { + TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr) + } else xpdr.downlinkLow + val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) } + + if (radio.isConnected) { + // Query PTT state — don't assume silence; filter broadcast noise + val isPtt = radio.readPttStatus() + Log.d(tag, "Split loop: isPTT=$isPtt txFreq=$txRadioFreq rxFreq=$rxRadioFreq") + + when (isPtt) { + true -> { + // PTT ON → radio's active VFO is VFO-B (TX) + // Update TX frequency on active VFO only + if (txRadioFreq != null) { + Log.d(tag, "Split loop TX update: ${txRadioFreq}Hz") + radio.setWorkingFrequency(txRadioFreq) + } + } + false -> { + // PTT OFF → radio's active VFO is VFO-A (RX) + // Update RX frequency on active VFO only + if (rxRadioFreq != null) { + Log.d(tag, "Split loop RX update: ${rxRadioFreq}Hz") + radio.setWorkingFrequency(rxRadioFreq) + } + } + null -> { + // Could not read PTT (broadcast noise race) — skip this tick + Log.d(tag, "Split loop: PTT read failed, skipping tick") + } + } + } + + _state.update { + it.copy( + txConnected = radio.isConnected, + rxConnected = false, // single radio + txFrequencyHz = txRadioFreq, + rxFrequencyHz = rxRadioFreq, + azimuth = Math.toDegrees(pos.azimuth), + elevation = Math.toDegrees(pos.elevation), + distance = pos.distance + ) + } + delay(1000) + } + } + + // ── Other IRadioTrackingService methods ───────────────────────────────── + override fun stopTracking() { trackingJob?.cancel() trackingJob = null @@ -288,7 +440,6 @@ class RadioTrackingService( TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted) } rxMode?.let { rx?.setMode(it) } - if (transponder.uplinkMode?.uppercase() == "FM") { _state.value.ctcssTone?.let { tone -> tx?.setCtcssTone(tone) @@ -302,21 +453,17 @@ class RadioTrackingService( transponder.uplinkLow != null -> transponder.uplinkLow!! else -> null } - // Show nominal frequencies immediately val rxNominal = if (txCenter != null) { TransponderMapper.mapUplinkToDownlink(txCenter, transponder) - } else { - // Downlink-only transponder (beacon etc.) - use downlink directly - transponder.downlinkLow - } + } else transponder.downlinkLow _state.update { it.copy( selectedTransponder = transponder, - txBaseFrequencyHz = txCenter, - txFrequencyHz = txCenter, - rxFrequencyHz = rxNominal, - txMode = transponder.uplinkMode, - rxMode = transponder.downlinkMode + txBaseFrequencyHz = txCenter, + txFrequencyHz = txCenter, + rxFrequencyHz = rxNominal, + txMode = transponder.uplinkMode, + rxMode = transponder.downlinkMode ?: transponder.uplinkMode?.let { m -> TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted) } @@ -353,5 +500,4 @@ class RadioTrackingService( } _state.update { it.copy(txMode = txMode, rxMode = rxMode) } } - } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index ceafa38b..a45e3481 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 42acc318..0b54975d 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 fcdc2667..edf70dd0 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 d0bb4ef6..e76e28e9 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IRadioController.kt @@ -38,4 +38,34 @@ interface IRadioController { suspend fun pttOn(): Boolean suspend fun pttOff(): Boolean + + // ── Extended operations (IC-705 / CI-V) ────────────────────────────── + + /** + * Read the current PTT status from the radio. + * Returns true if transmitting, false if receiving, null on error. + * Default: not supported. + */ + suspend fun readPttStatus(): Boolean? = null + + /** + * Select the active VFO. + * @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split). + * Default: no-op. + */ + suspend fun setVfo(vfoA: Boolean): Boolean = false + + /** + * Enable or disable SPLIT mode (TX on sub-VFO, RX on main VFO). + * Default: not supported. + */ + suspend fun setSplitMode(enabled: Boolean): Boolean = false + + /** + * Set the frequency of the currently active VFO. + * On radios that support it (IC-705: CMD 0x25 sub 0x00) this updates + * the active VFO without switching. + * Default: delegates to [setFrequency]. + */ + suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz) } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index a3a57557..385f7a84 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -22,11 +22,14 @@ import android.content.Context import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FilterChip import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -38,6 +41,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -438,28 +442,41 @@ private fun OutputChannelSection( } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun RadioControlDialog( initialSettings: RadioControlSettings, onDismiss: () -> Unit, onSave: (RadioControlSettings) -> Unit ) { - val context = androidx.compose.ui.platform.LocalContext.current - val padding = LocalSpacing.current.large - val baudRates = listOf(4800, 9600, 38400) - val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) } + val context = androidx.compose.ui.platform.LocalContext.current + val padding = LocalSpacing.current.large + val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) } val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) } - val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) } - val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) } - val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) } - val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) } - val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) } + val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) } + val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) } + val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) } + val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) } + val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) } + val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) } val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or "" + val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705 + val isSingleRadio = isIcom && splitMode.value + + // Reset split mode when switching away from IC-705 + if (!isIcom && splitMode.value) splitMode.value = false + + val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM + else RadioControlSettings.BAUD_RATES_YAESU + + // If current baud rate is not in the new list, default to the first available + if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first() + val pairedDevices = remember { try { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList() + manager.adapter?.bondedDevices?.map { it.name ?: "Unknown" to it.address } ?: emptyList() } catch (_: SecurityException) { emptyList() } @@ -468,84 +485,119 @@ fun RadioControlDialog( val onAccept = { onSave( RadioControlSettings( - enabled = enabled.value, - radioModel = radioModel.value, + enabled = enabled.value, + radioModel = radioModel.value, txRadioAddress = txAddress.value, - rxRadioAddress = rxAddress.value, - txRadioName = txName.value, - rxRadioName = rxName.value, - baudRate = baudRate.intValue + rxRadioAddress = if (isSingleRadio) "" else rxAddress.value, + txRadioName = txName.value, + rxRadioName = if (isSingleRadio) "" else rxName.value, + baudRate = baudRate.intValue, + splitMode = splitMode.value ) ) onDismiss() } + SharedDialog( - title = stringResource(R.string.rc_settings_title), + title = stringResource(R.string.rc_settings_title), onCancel = onDismiss, onAccept = onAccept ) { Column(modifier = Modifier.padding(horizontal = padding)) { + + // Enable switch Row( horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.rc_enable_switch)) Switch(checked = enabled.value, onCheckedChange = { enabled.value = it }) } Spacer(modifier = Modifier.height(6.dp)) - // Radio model selection + // Radio model — FlowRow so chips wrap on small screens Text( - stringResource(R.string.rc_radio_model), - fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, - color = androidx.compose.material3.MaterialTheme.colorScheme.primary + text = stringResource(R.string.rc_radio_model), + fontWeight = FontWeight.Medium, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary ) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { RadioControlSettings.SUPPORTED_RADIOS.forEach { model -> - androidx.compose.material3.FilterChip( + FilterChip( selected = radioModel.value == model, - onClick = { radioModel.value = model }, - label = { Text(model, fontSize = 12.sp) }, - enabled = enabled.value + onClick = { radioModel.value = model }, + label = { Text(model, fontSize = 12.sp) }, + enabled = enabled.value ) } } Spacer(modifier = Modifier.height(6.dp)) - // TX Radio selection - Text("TX Radio (Uplink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium) + // IC-705 split-mode toggle (only shown for IC-705) + if (isIcom) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Split mode (single radio)", fontWeight = FontWeight.Medium) + Text( + text = "Use VFO-A/B split on one IC-705 instead of two radios", + fontSize = 12.sp, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = splitMode.value, + onCheckedChange = { splitMode.value = it }, + enabled = enabled.value + ) + } + Spacer(modifier = Modifier.height(6.dp)) + } + + // TX Radio (always shown; in split mode this is the single IC-705) + val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)" + Text(txLabel, fontWeight = FontWeight.Medium) if (txAddress.value.isNotBlank()) { - Text("${txName.value} - ${txAddress.value}", fontSize = 13.sp) + Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp) } CardButton( - onClick = { selectingFor.value = "tx" }, - text = "Select TX Device", + onClick = { selectingFor.value = "tx" }, + text = if (isSingleRadio) "Select Device" else "Select TX Device", modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(6.dp)) - // RX Radio selection - Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium) - if (rxAddress.value.isNotBlank()) { - Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp) - } - CardButton( - onClick = { selectingFor.value = "rx" }, - text = "Select RX Device", - modifier = Modifier.fillMaxWidth() - ) - - // Paired devices list (shown when 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 + // 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)) + } + + // Paired device picker (inline, shown while selecting) + if (selectingFor.value.isNotBlank()) { + Text( + text = "Paired Bluetooth Devices:", + fontWeight = FontWeight.Medium, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(2.dp)) if (pairedDevices.isEmpty()) { - Text("No paired devices found. Pair your BT adapter in Android Bluetooth settings first.") + Text( + "No paired devices found. Pair your BT adapter in Android Bluetooth settings first.", + fontSize = 13.sp + ) } else { pairedDevices.forEach { (name, address) -> androidx.compose.material3.Surface( @@ -554,17 +606,17 @@ fun RadioControlDialog( .clickable { if (selectingFor.value == "tx") { txAddress.value = address - txName.value = name + txName.value = name } else { rxAddress.value = address - rxName.value = name + rxName.value = name } selectingFor.value = "" } .padding(vertical = 4.dp) ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text(name, modifier = Modifier.weight(1f)) @@ -573,23 +625,19 @@ fun RadioControlDialog( } } } + Spacer(modifier = Modifier.height(6.dp)) } - Spacer(modifier = Modifier.height(6.dp)) - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text("Baud Rate:") - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - baudRates.forEach { rate -> - CardButton( - onClick = { baudRate.intValue = rate }, - text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(), - modifier = Modifier - ) - } + // Baud rate — FlowRow so all chips fit on narrow screens + Text("Baud Rate:", fontWeight = FontWeight.Medium) + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + baudRates.forEach { rate -> + FilterChip( + selected = rate == baudRate.intValue, + onClick = { baudRate.intValue = rate }, + label = { Text(rate.toString(), fontSize = 12.sp) }, + enabled = enabled.value + ) } } }