Add initial support for Icom IC-705 radio

- Added support for Icom IC-705 radio model with split mode functionality.
- Updated RadioTrackingService to handle single-radio split mode and dual-radio configurations.
- Modified MainContainer to provide appropriate radio controllers based on selected model.
- Enhanced SettingsRepo to include split mode preference in radio control settings.
- Updated UI in SettingsDialog to allow toggling of split mode for IC-705 and adjust device selection accordingly.
- Improved handling of baud rates based on selected radio model.
- Added extended operations in IRadioController for IC-705 specific commands.
This commit is contained in:
Rui Oliveira committed 2026-07-24 20:07:11 +01:00
1 parent b17ea2d918
commit d700432829
8 files changed
+961 -126

No files matched your search

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Long, String>? = 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<Byte>()
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
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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 <DEST> <SRC> <CMD> [<SUB>] [<DATA...>] 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<String, Byte> = 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<Byte, String> = 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 <CMD>" 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<Long, String>? {
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
)
}
@@ -19,8 +19,10 @@ package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.util.Log 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.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass 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.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
@@ -51,28 +53,48 @@ class RadioTrackingService(
private var rxController: IRadioController? = null private var rxController: IRadioController? = null
private var trackingJob: Job? = null private var trackingJob: Job? = null
// ── Connection ──────────────────────────────────────────────────────────
override suspend fun connectRadios() { override suspend fun connectRadios() {
// Disconnect old controllers if any
txController?.disconnect() txController?.disconnect()
rxController?.disconnect() rxController?.disconnect()
// Read current addresses from settings
val rcSettings = settingsRepo.radioControlSettings.value val rcSettings = settingsRepo.radioControlSettings.value
val txAddr = rcSettings.txRadioAddress val txAddr = rcSettings.txRadioAddress
val rxAddr = rcSettings.rxRadioAddress 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 (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()) { if (txAddr.isBlank() && rxAddr.isBlank()) {
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") } _state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
return return
} }
val tx = makeController(isIcom, txAddr)
val tx = Ft817Controller(bluetoothManager, txAddr) val rx = makeController(isIcom, rxAddr)
val rx = Ft817Controller(bluetoothManager, rxAddr)
txController = tx txController = tx
rxController = rx rxController = rx
_state.update { it.copy(errorMessage = null) } _state.update { it.copy(errorMessage = null) }
val txOk = if (txAddr.isNotBlank()) tx.connect() else false val txOk = if (txAddr.isNotBlank()) tx.connect() else false
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
@@ -88,7 +110,13 @@ class RadioTrackingService(
} }
) )
} }
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() { override suspend fun disconnectRadios() {
stopTracking() stopTracking()
@@ -96,15 +124,11 @@ class RadioTrackingService(
rxController?.disconnect() rxController?.disconnect()
txController = null txController = null
rxController = null rxController = null
_state.update { _state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
it.copy(
txConnected = false,
rxConnected = false,
isActive = false
)
}
} }
// ── Tracking ────────────────────────────────────────────────────────────
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) { override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
_state.update { _state.update {
it.copy( it.copy(
@@ -115,26 +139,44 @@ class RadioTrackingService(
) )
} }
trackingJob?.cancel() trackingJob?.cancel()
trackingJob = appScope.launch {
// Set modes on both radios at tracking start 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 tx = txController
val rx = rxController val rx = rxController
// Initial setup: set band/mode/CTCSS on both radios
val txMode = transponder.uplinkMode val txMode = transponder.uplinkMode
val rxMode = transponder.downlinkMode val rxMode = transponder.downlinkMode
?: transponder.uplinkMode?.let { ?: transponder.uplinkMode?.let {
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted) TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
} }
Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode")
if (tx != null && tx.isConnected && txMode != null) { if (tx != null && tx.isConnected && txMode != null) {
Log.d(tag, "Setting TX mode: $txMode")
tx.setMode(txMode) tx.setMode(txMode)
Log.i(tag, "TX mode set to $txMode")
} }
if (rx != null && rx.isConnected && rxMode != null) { if (rx != null && rx.isConnected && rxMode != null) {
Log.d(tag, "Setting RX mode: $rxMode")
rx.setMode(rxMode) rx.setMode(rxMode)
Log.i(tag, "RX mode set to $rxMode")
} }
// Set CTCSS if FM
if (txMode?.uppercase() == "FM") { if (txMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone -> _state.value.ctcssTone?.let { tone ->
Log.d(tag, "Setting CTCSS: ${tone}Hz")
tx?.setCtcssTone(tone) tx?.setCtcssTone(tone)
tx?.setCtcssMode(true) tx?.setCtcssMode(true)
} }
@@ -143,7 +185,7 @@ class RadioTrackingService(
var lastSetTxFreq = 0.0 var lastSetTxFreq = 0.0
var lastSetRxFreq = 0.0 var lastSetRxFreq = 0.0
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning var tuningRadio = ""
var lastReadFreq = 0L var lastReadFreq = 0L
var stableCount = 0 var stableCount = 0
@@ -155,39 +197,29 @@ class RadioTrackingService(
val xpdr = currentState.selectedTransponder ?: break val xpdr = currentState.selectedTransponder ?: break
var txBaseFreq = currentState.txBaseFrequencyHz var txBaseFreq = currentState.txBaseFrequencyHz
val stationPos = settingsRepo.stationPosition.value val stationPos = settingsRepo.stationPosition.value
val timeNow = System.currentTimeMillis() val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
val txNow = txController
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow) val rxNow = rxController
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 val v = pos.distanceRate * 1000.0
if (tuningRadio.isNotEmpty()) { if (tuningRadio.isNotEmpty()) {
// --- User is tuning: keep reading, wait for stabilization --- val radio = if (tuningRadio == "tx") txNow else rxNow
val radio = if (tuningRadio == "tx") tx else rx
if (radio != null && radio.isConnected) { if (radio != null && radio.isConnected) {
val readResult = radio.readFrequencyAndMode() val read = radio.readFrequencyAndMode()
if (readResult != null) { if (read != null) {
val (freq, _) = readResult val (freq, _) = read
if (kotlin.math.abs(freq - lastReadFreq) <= 20) { if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
stableCount++ else { stableCount = 0; lastReadFreq = freq }
} else {
stableCount = 0
lastReadFreq = freq
}
// Stable for 2 reads → user stopped turning
if (stableCount >= 2) { if (stableCount >= 2) {
if (tuningRadio == "tx" && txBaseFreq != null) { if (tuningRadio == "tx" && txBaseFreq != null) {
val newBase = (freq.toDouble() * c / (c + v)).toLong() val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
if (newBase > 0) { if (newBase > 0) {
txBaseFreq = newBase txBaseFreq = newBase
_state.update { it.copy(txBaseFrequencyHz = newBase) } _state.update { it.copy(txBaseFrequencyHz = newBase) }
Log.i(tag, "TX tuning done → base=$newBase") Log.i(tag, "TX tuning done → base=$newBase")
} }
} else if (tuningRadio == "rx") { } else if (tuningRadio == "rx") {
val rxNominal = (freq.toDouble() * c / (c - v)).toLong() val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr) val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
if (newTxBase != null && newTxBase > 0) { if (newTxBase != null && newTxBase > 0) {
txBaseFreq = newTxBase txBaseFreq = newTxBase
@@ -203,62 +235,48 @@ class RadioTrackingService(
} }
} }
} else { } else {
// --- Normal tracking: read, detect changes, command --- // Detect manual dial changes
if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) {
// TX dial feedback val read = txNow.readFrequencyAndMode()
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) { if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) {
val readResult = tx.readFrequencyAndMode()
if (readResult != null) {
val (actualTxFreq, _) = readResult
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
tuningRadio = "tx" tuningRadio = "tx"
lastReadFreq = actualTxFreq lastReadFreq = read.first
stableCount = 0 stableCount = 0
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)") 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()
// RX dial feedback (only if TX not tuning) if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) {
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" tuningRadio = "rx"
lastReadFreq = actualRxFreq lastReadFreq = read.first
stableCount = 0 stableCount = 0
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)") Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)")
}
} }
} }
} }
// Compute Doppler-corrected frequencies
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) } val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
val rxBaseFreq = if (txBaseFreq != null) { val rxBaseFreq = if (txBaseFreq != null) {
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr) TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
} else { } else xpdr.downlinkLow
xpdr.downlinkLow
}
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) } val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
// Command radios (only when not tuning)
if (tuningRadio.isEmpty()) { if (tuningRadio.isEmpty()) {
if (tx != null && tx.isConnected && txRadioFreq != null) { if (txNow != null && txNow.isConnected && txRadioFreq != null) {
tx.setFrequency(txRadioFreq) txNow.setFrequency(txRadioFreq)
lastSetTxFreq = txRadioFreq.toDouble() lastSetTxFreq = txRadioFreq.toDouble()
} }
if (rx != null && rx.isConnected && rxRadioFreq != null) { if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
rx.setFrequency(rxRadioFreq) rxNow.setFrequency(rxRadioFreq)
lastSetRxFreq = rxRadioFreq.toDouble() lastSetRxFreq = rxRadioFreq.toDouble()
} }
} }
_state.update { _state.update {
it.copy( it.copy(
txConnected = tx?.isConnected ?: false, txConnected = txNow?.isConnected ?: false,
rxConnected = rx?.isConnected ?: false, rxConnected = rxNow?.isConnected ?: false,
txFrequencyHz = txRadioFreq, txFrequencyHz = txRadioFreq,
rxFrequencyHz = rxRadioFreq, rxFrequencyHz = rxRadioFreq,
azimuth = Math.toDegrees(pos.azimuth), azimuth = Math.toDegrees(pos.azimuth),
@@ -266,12 +284,146 @@ class RadioTrackingService(
distance = pos.distance distance = pos.distance
) )
} }
delay(1000) 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() { override fun stopTracking() {
trackingJob?.cancel() trackingJob?.cancel()
trackingJob = null trackingJob = null
@@ -288,7 +440,6 @@ class RadioTrackingService(
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted) TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
} }
rxMode?.let { rx?.setMode(it) } rxMode?.let { rx?.setMode(it) }
if (transponder.uplinkMode?.uppercase() == "FM") { if (transponder.uplinkMode?.uppercase() == "FM") {
_state.value.ctcssTone?.let { tone -> _state.value.ctcssTone?.let { tone ->
tx?.setCtcssTone(tone) tx?.setCtcssTone(tone)
@@ -302,13 +453,9 @@ class RadioTrackingService(
transponder.uplinkLow != null -> transponder.uplinkLow!! transponder.uplinkLow != null -> transponder.uplinkLow!!
else -> null else -> null
} }
// Show nominal frequencies immediately
val rxNominal = if (txCenter != null) { val rxNominal = if (txCenter != null) {
TransponderMapper.mapUplinkToDownlink(txCenter, transponder) TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
} else { } else transponder.downlinkLow
// Downlink-only transponder (beacon etc.) - use downlink directly
transponder.downlinkLow
}
_state.update { _state.update {
it.copy( it.copy(
selectedTransponder = transponder, selectedTransponder = transponder,
@@ -353,5 +500,4 @@ class RadioTrackingService(
} }
_state.update { it.copy(txMode = txMode, rxMode = rxMode) } _state.update { it.copy(txMode = txMode, rxMode = rxMode) }
} }
} }
@@ -26,6 +26,7 @@ import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller 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.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo 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.AudioCapture
import com.rtbishop.look4sat.core.data.usecase.SaveImage import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast 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.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioController import com.rtbishop.look4sat.core.domain.repository.IRadioController
@@ -107,14 +109,24 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideTxRadioController(): IRadioController { override fun provideTxRadioController(): IRadioController {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val address = settingsRepo.radioControlSettings.value.txRadioAddress val settings = settingsRepo.radioControlSettings.value
return Ft817Controller(manager, address) val address = settings.txRadioAddress
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
Ic705Controller(manager, address)
} else {
Ft817Controller(manager, address)
}
} }
override fun provideRxRadioController(): IRadioController { override fun provideRxRadioController(): IRadioController {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val address = settingsRepo.radioControlSettings.value.rxRadioAddress val settings = settingsRepo.radioControlSettings.value
return Ft817Controller(manager, address) val address = settings.rxRadioAddress
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
Ic705Controller(manager, address)
} else {
Ft817Controller(manager, address)
}
} }
override fun provideSensorsRepo(): ISensorsRepo { override fun provideSensorsRepo(): ISensorsRepo {
@@ -383,6 +383,7 @@ class SettingsRepo(
private val keyTxRadioName = "txRadioName" private val keyTxRadioName = "txRadioName"
private val keyRxRadioName = "rxRadioName" private val keyRxRadioName = "rxRadioName"
private val keyRadioBaudRate = "radioBaudRate" private val keyRadioBaudRate = "radioBaudRate"
private val keyRadioSplitMode = "radioSplitMode"
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings()) private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
@@ -396,18 +397,20 @@ class SettingsRepo(
putString(keyTxRadioName, settings.txRadioName) putString(keyTxRadioName, settings.txRadioName)
putString(keyRxRadioName, settings.rxRadioName) putString(keyRxRadioName, settings.rxRadioName)
putInt(keyRadioBaudRate, settings.baudRate) putInt(keyRadioBaudRate, settings.baudRate)
putBoolean(keyRadioSplitMode, settings.splitMode)
} }
_radioControlSettings.value = settings _radioControlSettings.value = settings
} }
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings( private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
enabled = preferences.getBoolean(keyRadioControlEnabled, false), 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) ?: "", txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "", rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio", txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio", rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
baudRate = preferences.getInt(keyRadioBaudRate, 4800) baudRate = preferences.getInt(keyRadioBaudRate, 4800),
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
) )
//endregion //endregion
} }
@@ -74,12 +74,20 @@ data class RadioControlSettings(
val rxRadioAddress: String, val rxRadioAddress: String,
val txRadioName: String, val txRadioName: String,
val rxRadioName: 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 { companion object {
val SUPPORTED_RADIOS = listOf( const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
"Yaesu FT-817/818", const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
"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)
} }
} }
@@ -38,4 +38,34 @@ interface IRadioController {
suspend fun pttOn(): Boolean suspend fun pttOn(): Boolean
suspend fun pttOff(): 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)
} }
@@ -22,11 +22,14 @@ import android.content.Context
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -38,6 +41,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -438,6 +442,7 @@ private fun OutputChannelSection(
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun RadioControlDialog( fun RadioControlDialog(
initialSettings: RadioControlSettings, initialSettings: RadioControlSettings,
@@ -446,9 +451,9 @@ fun RadioControlDialog(
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
val baudRates = listOf(4800, 9600, 38400)
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) } val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) } val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) } val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) } val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) } val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
@@ -456,10 +461,22 @@ fun RadioControlDialog(
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) } val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or "" 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 { val pairedDevices = remember {
try { try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager 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) { } catch (_: SecurityException) {
emptyList() emptyList()
} }
@@ -471,20 +488,24 @@ fun RadioControlDialog(
enabled = enabled.value, enabled = enabled.value,
radioModel = radioModel.value, radioModel = radioModel.value,
txRadioAddress = txAddress.value, txRadioAddress = txAddress.value,
rxRadioAddress = rxAddress.value, rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
txRadioName = txName.value, txRadioName = txName.value,
rxRadioName = rxName.value, rxRadioName = if (isSingleRadio) "" else rxName.value,
baudRate = baudRate.intValue baudRate = baudRate.intValue,
splitMode = splitMode.value
) )
) )
onDismiss() onDismiss()
} }
SharedDialog( SharedDialog(
title = stringResource(R.string.rc_settings_title), title = stringResource(R.string.rc_settings_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept onAccept = onAccept
) { ) {
Column(modifier = Modifier.padding(horizontal = padding)) { Column(modifier = Modifier.padding(horizontal = padding)) {
// Enable switch
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -495,15 +516,15 @@ fun RadioControlDialog(
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
// Radio model selection // Radio model — FlowRow so chips wrap on small screens
Text( Text(
stringResource(R.string.rc_radio_model), text = stringResource(R.string.rc_radio_model),
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, fontWeight = FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary color = androidx.compose.material3.MaterialTheme.colorScheme.primary
) )
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
RadioControlSettings.SUPPORTED_RADIOS.forEach { model -> RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
androidx.compose.material3.FilterChip( FilterChip(
selected = radioModel.value == model, selected = radioModel.value == model,
onClick = { radioModel.value = model }, onClick = { radioModel.value = model },
label = { Text(model, fontSize = 12.sp) }, label = { Text(model, fontSize = 12.sp) },
@@ -513,39 +534,70 @@ fun RadioControlDialog(
} }
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
// TX Radio selection // IC-705 split-mode toggle (only shown for IC-705)
Text("TX Radio (Uplink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium) 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()) { if (txAddress.value.isNotBlank()) {
Text("${txName.value} - ${txAddress.value}", fontSize = 13.sp) Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp)
} }
CardButton( CardButton(
onClick = { selectingFor.value = "tx" }, onClick = { selectingFor.value = "tx" },
text = "Select TX Device", text = if (isSingleRadio) "Select Device" else "Select TX Device",
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
// RX Radio selection // RX Radio (hidden in split mode — the same radio handles both)
Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium) if (!isSingleRadio) {
Text("RX Radio (Downlink)", fontWeight = FontWeight.Medium)
if (rxAddress.value.isNotBlank()) { if (rxAddress.value.isNotBlank()) {
Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp) Text("${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
} }
CardButton( CardButton(
onClick = { selectingFor.value = "rx" }, onClick = { selectingFor.value = "rx" },
text = "Select RX Device", text = "Select RX Device",
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// Paired devices list (shown when selecting)
if (selectingFor.value.isNotBlank()) {
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
}
// Paired device picker (inline, shown while selecting)
if (selectingFor.value.isNotBlank()) {
Text( Text(
text = "Paired Bluetooth Devices:", text = "Paired Bluetooth Devices:",
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, fontWeight = FontWeight.Medium,
color = androidx.compose.material3.MaterialTheme.colorScheme.primary color = androidx.compose.material3.MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.height(2.dp))
if (pairedDevices.isEmpty()) { 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 { } else {
pairedDevices.forEach { (name, address) -> pairedDevices.forEach { (name, address) ->
androidx.compose.material3.Surface( androidx.compose.material3.Surface(
@@ -573,25 +625,21 @@ fun RadioControlDialog(
} }
} }
} }
Spacer(modifier = Modifier.height(6.dp))
} }
Spacer(modifier = Modifier.height(6.dp)) // Baud rate — FlowRow so all chips fit on narrow screens
Row( Text("Baud Rate:", fontWeight = FontWeight.Medium)
horizontalArrangement = Arrangement.SpaceBetween, FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text("Baud Rate:")
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
baudRates.forEach { rate -> baudRates.forEach { rate ->
CardButton( FilterChip(
selected = rate == baudRate.intValue,
onClick = { baudRate.intValue = rate }, onClick = { baudRate.intValue = rate },
text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(), label = { Text(rate.toString(), fontSize = 12.sp) },
modifier = Modifier enabled = enabled.value
) )
} }
} }
} }
} }
}
} }