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