Added support for Icom IC-705 CAT (#229)
This commit is contained in:
8 files changed
+1327
-292
No files matched your search
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <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
|
||||
// Enter VFO mode — frequency/mode commands return FA if the radio
|
||||
// is in memory-channel mode. Safe to send regardless of current state.
|
||||
Log.i(tag, "Connected to $deviceAddress — entering VFO mode")
|
||||
val vfoCmd = IcomCivProtocol.buildEnterVfoModeCommand()
|
||||
Log.d(tag, "CMD enterVfoMode → ${IcomCivProtocol.toHex(vfoCmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(vfoCmd) }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Connect error: ${e.message}")
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun disconnect() {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
inputStream?.close()
|
||||
outputStream?.close()
|
||||
socket?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Disconnect error: ${e.message}")
|
||||
} finally {
|
||||
inputStream = null
|
||||
outputStream = null
|
||||
socket = null
|
||||
isConnected = false
|
||||
Log.i(tag, "Disconnected from $deviceAddress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── IRadioController – standard operations ──────────────────────────────
|
||||
|
||||
override suspend fun setFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "setFrequency: ${frequencyHz}Hz")
|
||||
ioMutex.withLock {
|
||||
val cmd = IcomCivProtocol.buildSetFreqCommand(frequencyHz)
|
||||
Log.d(tag, "CMD setFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||
sendAndWaitAck(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setMode(mode: String): Boolean = withContext(Dispatchers.IO) {
|
||||
val cmd = IcomCivProtocol.buildSetModeCommand(mode) ?: run {
|
||||
Log.w(tag, "setMode: unknown mode '$mode'")
|
||||
return@withContext false
|
||||
}
|
||||
Log.d(tag, "setMode: $mode")
|
||||
Log.d(tag, "CMD setMode → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
override suspend fun setCtcssMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "setCtcssMode: $enabled")
|
||||
val cmd = IcomCivProtocol.buildCtcssModeCommand(enabled)
|
||||
Log.d(tag, "CMD ctcssMode → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
override suspend fun setCtcssTone(toneHz: Double): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "setCtcssTone: ${toneHz}Hz")
|
||||
val cmd = IcomCivProtocol.buildSetCtcssToneCommand(toneHz)
|
||||
Log.d(tag, "CMD ctcssTone → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
override suspend fun readFrequencyAndMode(): Pair<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: not used for IC-705")
|
||||
true
|
||||
}
|
||||
|
||||
override suspend fun pttOff(): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.w(tag, "pttOff: not used for IC-705")
|
||||
true
|
||||
}
|
||||
|
||||
// ── IRadioController – IC-705 extended operations ───────────────────────
|
||||
|
||||
/** Select the band for [frequencyHz] via CMD 0x1A sub 0x00 (band stacking register). */
|
||||
override suspend fun setBand(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
val cmd = IcomCivProtocol.buildBandSelectCommand(frequencyHz) ?: run {
|
||||
Log.w(tag, "setBand: no band code for ${frequencyHz}Hz — skipping")
|
||||
return@withContext false
|
||||
}
|
||||
Log.d(tag, "CMD setBand (${frequencyHz}Hz) → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
/** Select VFO-A (main/RX) or VFO-B (sub/TX). */
|
||||
override suspend fun setVfo(vfoA: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||
val cmd = if (vfoA) IcomCivProtocol.buildSelectVfoACommand()
|
||||
else IcomCivProtocol.buildSelectVfoBCommand()
|
||||
Log.d(tag, "CMD selectVFO${if (vfoA) "A" else "B"} → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable SPLIT mode (TX on sub-VFO while listening on main VFO).
|
||||
*/
|
||||
override suspend fun setSplitMode(enabled: Boolean): Boolean = withContext(Dispatchers.IO) {
|
||||
val cmd = IcomCivProtocol.buildSplitModeCommand(enabled)
|
||||
Log.d(tag, "CMD split ${if (enabled) "ON" else "OFF"} → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the frequency of the **currently active** VFO (CMD 0x25 sub 0x00).
|
||||
* In split mode the radio automatically switches active VFO on PTT, so
|
||||
* always writing to the active VFO is the correct strategy.
|
||||
*/
|
||||
override suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "setWorkingFrequency (0x25/00): ${frequencyHz}Hz")
|
||||
val cmd = IcomCivProtocol.buildSetWorkingFreqCommand(frequencyHz)
|
||||
Log.d(tag, "CMD setWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TX VFO frequency via CMD 0x25 sub 0x01 (unselected VFO).
|
||||
* Sent every tracking cycle in split mode alongside [setWorkingFrequency].
|
||||
*/
|
||||
override suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
Log.d(tag, "setTxVfoFrequency (0x25/01): ${frequencyHz}Hz")
|
||||
val cmd = IcomCivProtocol.buildSetUnselectedVfoFreqCommand(frequencyHz)
|
||||
Log.d(tag, "CMD setTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||
ioMutex.withLock { sendAndWaitAck(cmd) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the frequency of the currently active VFO (CMD 0x25 sub 0x00).
|
||||
* Used for tuning detection in split mode.
|
||||
*/
|
||||
override suspend fun readWorkingFrequency(): Long? = withContext(Dispatchers.IO) {
|
||||
ioMutex.withLock {
|
||||
val cmd = IcomCivProtocol.buildReadWorkingFreqCommand()
|
||||
Log.d(tag, "CMD readWorkingFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
||||
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
||||
Log.d(tag, "readWorkingFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
||||
if (payload.size < 6) {
|
||||
Log.w(tag, "readWorkingFreq: payload too short (${payload.size} bytes)")
|
||||
return@withContext null
|
||||
}
|
||||
val freqBcd = payload.sliceArray(1..5)
|
||||
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
||||
Log.d(tag, "readWorkingFreq: ${freq}Hz")
|
||||
freq
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the frequency of the inactive/TX VFO (CMD 0x25 sub 0x01).
|
||||
* Used for tuning detection in split mode.
|
||||
*/
|
||||
override suspend fun readTxVfoFrequency(): Long? = withContext(Dispatchers.IO) {
|
||||
ioMutex.withLock {
|
||||
val cmd = IcomCivProtocol.buildReadTxVfoFreqCommand()
|
||||
Log.d(tag, "CMD readTxVfoFreq → ${IcomCivProtocol.toHex(cmd)}")
|
||||
val payload = sendAndReadResponse(cmd, IcomCivProtocol.CMD_SELECTED_VFO_FREQ) ?: return@withContext null
|
||||
// Response payload: [sub] [5 freq bytes] — CMD byte already stripped by parseResponse
|
||||
Log.d(tag, "readTxVfoFreq: got ${payload.size} bytes: ${IcomCivProtocol.toHex(payload)}")
|
||||
if (payload.size < 6) {
|
||||
Log.w(tag, "readTxVfoFreq: payload too short (${payload.size} bytes)")
|
||||
return@withContext null
|
||||
}
|
||||
val freqBcd = payload.sliceArray(1..5)
|
||||
val freq = IcomCivProtocol.decodeFrequencyBcd(freqBcd)
|
||||
Log.d(tag, "readTxVfoFreq: ${freq}Hz")
|
||||
freq
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal I/O helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write [cmd] to the radio and drain the input stream for up to
|
||||
* [ACK_TIMEOUT_MS], looking for an OK/NG acknowledgement frame.
|
||||
*/
|
||||
private suspend fun sendAndWaitAck(cmd: ByteArray): Boolean {
|
||||
if (!write(cmd)) return false
|
||||
delay(WRITE_SETTLE_MS)
|
||||
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
||||
val ok = IcomCivProtocol.containsAck(buf)
|
||||
if (!ok) Log.w(tag, "ACK not found in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
||||
return ok
|
||||
}
|
||||
|
||||
/**
|
||||
* Write [cmd] to the radio and drain the input stream for up to
|
||||
* [ACK_TIMEOUT_MS], scanning for a response frame carrying [expectCmd].
|
||||
* Returns the payload bytes of that frame, or null on timeout/error.
|
||||
*/
|
||||
private suspend fun sendAndReadResponse(cmd: ByteArray, expectCmd: Byte): ByteArray? {
|
||||
if (!write(cmd)) return null
|
||||
delay(WRITE_SETTLE_MS)
|
||||
val buf = drainWithTimeout(ACK_TIMEOUT_MS)
|
||||
val response = IcomCivProtocol.parseResponse(buf, expectCmd)
|
||||
if (response == null) {
|
||||
Log.w(tag, "No response for cmd 0x${String.format("%02X", expectCmd.toInt() and 0xFF)} " +
|
||||
"in ${buf.size} bytes: ${IcomCivProtocol.toHex(buf)}")
|
||||
}
|
||||
return response?.payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain whatever bytes the radio has buffered within a [timeoutMs] window.
|
||||
* Exits early as soon as a complete CI-V frame addressed to us is present
|
||||
* in the buffer (i.e., FE FE E0 A4 … FD), so we don't waste the remaining
|
||||
* timeout on responses that already arrived.
|
||||
*/
|
||||
private suspend fun drainWithTimeout(timeoutMs: Long): ByteArray {
|
||||
val result = mutableListOf<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))
|
||||
// Exit early once we have a complete frame for us
|
||||
if (hasCompleteFrameForUs(result)) break
|
||||
}
|
||||
} else {
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Drain error: ${e.message}")
|
||||
isConnected = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return result.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if [buf] contains a complete CI-V frame addressed to the
|
||||
* controller (FE FE [ADDR_CTRL] [ADDR_IC705] … FD).
|
||||
* CI-V data bytes cannot be 0xFD, so the first 0xFD after the header is
|
||||
* always the frame terminator.
|
||||
*/
|
||||
private fun hasCompleteFrameForUs(buf: List<Byte>): Boolean {
|
||||
var i = 0
|
||||
while (i < buf.size - 4) {
|
||||
if (buf[i] == IcomCivProtocol.PREAMBLE &&
|
||||
buf[i + 1] == IcomCivProtocol.PREAMBLE &&
|
||||
buf[i + 2] == IcomCivProtocol.ADDR_CTRL &&
|
||||
buf[i + 3] == IcomCivProtocol.ADDR_IC705
|
||||
) {
|
||||
for (k in i + 4 until buf.size) {
|
||||
if (buf[k] == IcomCivProtocol.END_OF_MSG) return true
|
||||
}
|
||||
return false // header found but no FD yet
|
||||
}
|
||||
i++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun write(bytes: ByteArray): Boolean {
|
||||
return try {
|
||||
outputStream?.write(bytes)
|
||||
outputStream?.flush()
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Write error: ${e.message}")
|
||||
isConnected = false
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <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
|
||||
/**
|
||||
* Select operating mode (VFO vs memory-channel).
|
||||
* Sub 0x00 = VFO mode. Must be sent after connect if the radio is in
|
||||
* memory-channel mode — frequency/mode commands return FA until it is.
|
||||
*/
|
||||
const val CMD_SELECT_OP_MODE: Byte = 0x08
|
||||
/** Set repeater duplex / SPLIT. */
|
||||
const val CMD_DUPLEX_SPLIT: Byte = 0x0F
|
||||
/** Band stacking register / band select (sub 0x00 = select, data = BCD band number). */
|
||||
const val CMD_BAND_SELECT: Byte = 0x1A
|
||||
/** Read/write CTCSS tone frequency. */
|
||||
const val CMD_CTCSS_TONE: Byte = 0x1B
|
||||
/** Read/write misc settings (used for enabling CTCSS encode). */
|
||||
const val CMD_MISC_SETTING: Byte = 0x16
|
||||
/** Read/write selected-VFO frequency (cmd 0x25). */
|
||||
const val CMD_SELECTED_VFO_FREQ: Byte = 0x25
|
||||
|
||||
// ── Sub-command bytes ──────────────────────────────────────────────────
|
||||
/** Sub for CMD_SELECT_VFO: select VFO-A (main). */
|
||||
const val SUB_VFO_A: Byte = 0x00
|
||||
/** Sub for CMD_SELECT_VFO: select VFO-B (sub). */
|
||||
const val SUB_VFO_B: Byte = 0x01
|
||||
/** Sub for CMD_DUPLEX_SPLIT: simplex / split OFF. */
|
||||
const val SUB_SPLIT_OFF: Byte = 0x00
|
||||
/** Sub for CMD_DUPLEX_SPLIT: SPLIT ON. */
|
||||
const val SUB_SPLIT_ON: Byte = 0x01
|
||||
/** Sub for CMD_SELECTED_VFO_FREQ: selected (active) VFO frequency. */
|
||||
const val SUB_SELECTED_VFO: Byte = 0x00
|
||||
/** Sub for CMD_SELECTED_VFO_FREQ: unselected (inactive / TX in split) VFO frequency. */
|
||||
const val SUB_UNSELECTED_VFO: Byte = 0x01
|
||||
/** Sub for CMD_MISC_SETTING: CTCSS/DTCS tone squelch. */
|
||||
const val SUB_CTCSS_SETTING: Byte = 0x42.toByte()
|
||||
|
||||
// ── Mode bytes ────────────────────────────────────────────────────────
|
||||
/** Maps mode strings (upper-case) → IC-705 mode bytes. */
|
||||
val MODE_TO_BYTE: Map<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,
|
||||
"AFSK" to 0x05 // AFSK uses FM modulation
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
/**
|
||||
* Set unselected-VFO frequency via CMD 0x25 sub 0x01.
|
||||
* In split mode while PTT is pressed the IC-705 makes VFO-B active, so
|
||||
* this command targets VFO-A (the RX VFO) — and vice-versa when in RX.
|
||||
* Use this to update the TX VFO when PTT is on.
|
||||
*/
|
||||
fun buildSetUnselectedVfoFreqCommand(frequencyHz: Long): ByteArray {
|
||||
return frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO, *encodeFrequencyBcd(frequencyHz))
|
||||
}
|
||||
|
||||
/** Read operating frequency (CMD 0x03). */
|
||||
fun buildReadFreqCommand(): ByteArray = frame(CMD_READ_FREQ)
|
||||
|
||||
/** Read selected (active) VFO frequency (CMD 0x25 sub 0x00). */
|
||||
fun buildReadWorkingFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_SELECTED_VFO)
|
||||
|
||||
/** Read unselected (inactive/TX in split) VFO frequency (CMD 0x25 sub 0x01). */
|
||||
fun buildReadTxVfoFreqCommand(): ByteArray = frame(CMD_SELECTED_VFO_FREQ, SUB_UNSELECTED_VFO)
|
||||
|
||||
/**
|
||||
* Select band via CMD 0x1A sub 0x00.
|
||||
* Band codes are BCD-numbered: 1=160m, 2=80m, …, 9=10m, 0x10=6m, 0x11=2m, 0x12=70cm, 0x13=23cm.
|
||||
* Returns null if [frequencyHz] doesn't fall in a known amateur band.
|
||||
*/
|
||||
fun buildBandSelectCommand(frequencyHz: Long): ByteArray? {
|
||||
val code = bandCodeForFrequency(frequencyHz) ?: return null
|
||||
return frame(CMD_BAND_SELECT, 0x00, code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a frequency in Hz to the IC-705 band stacking register code.
|
||||
* Codes are BCD (band number in decimal expressed as hex nibbles).
|
||||
*/
|
||||
fun bandCodeForFrequency(frequencyHz: Long): Byte? = when {
|
||||
frequencyHz in 1_800_000L ..1_999_999L -> 0x01 // 160 m
|
||||
frequencyHz in 3_500_000L ..3_999_999L -> 0x02 // 80 m
|
||||
frequencyHz in 7_000_000L ..7_299_999L -> 0x03 // 40 m
|
||||
frequencyHz in 10_100_000L ..10_149_999L -> 0x04 // 30 m
|
||||
frequencyHz in 14_000_000L ..14_349_999L -> 0x05 // 20 m
|
||||
frequencyHz in 18_068_000L ..18_167_999L -> 0x06 // 17 m
|
||||
frequencyHz in 21_000_000L ..21_449_999L -> 0x07 // 15 m
|
||||
frequencyHz in 24_890_000L ..24_989_999L -> 0x08 // 12 m
|
||||
frequencyHz in 28_000_000L ..29_699_999L -> 0x09 // 10 m
|
||||
frequencyHz in 50_000_000L ..53_999_999L -> 0x10 // 6 m (BCD 10)
|
||||
frequencyHz in 144_000_000L ..147_999_999L -> 0x11 // 2 m (BCD 11)
|
||||
frequencyHz in 420_000_000L ..449_999_999L -> 0x12 // 70 cm (BCD 12)
|
||||
frequencyHz in 1_240_000_000L ..1_299_999_999L -> 0x13 // 23 cm (BCD 13)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Set operating mode (CMD 0x06). Filter byte is omitted — radio uses its default filter for the mode. */
|
||||
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||
return frame(CMD_SET_MODE, modeByte)
|
||||
}
|
||||
|
||||
/** Select VFO-A (CMD 0x07 sub 0x00). */
|
||||
fun buildSelectVfoACommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_A)
|
||||
|
||||
/** Select VFO-B (CMD 0x07 sub 0x01). */
|
||||
fun buildSelectVfoBCommand(): ByteArray = frame(CMD_SELECT_VFO, SUB_VFO_B)
|
||||
|
||||
/**
|
||||
* Enter VFO operating mode (CMD 0x08 sub 0x00).
|
||||
* Sent after connect — if the radio is in memory-channel mode frequency
|
||||
* and mode commands return FA until this is issued.
|
||||
*/
|
||||
fun buildEnterVfoModeCommand(): ByteArray = frame(CMD_SELECT_OP_MODE, 0x00)
|
||||
|
||||
/** Enable or disable SPLIT mode (CMD 0x0F). */
|
||||
fun buildSplitModeCommand(enable: Boolean): ByteArray {
|
||||
val sub = if (enable) SUB_SPLIT_ON else SUB_SPLIT_OFF
|
||||
return frame(CMD_DUPLEX_SPLIT, sub)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable CTCSS encode (CMD 0x16 sub 0x42).
|
||||
* 0x01 = CTCSS encoder ON, 0x00 = OFF.
|
||||
*/
|
||||
fun buildCtcssModeCommand(enabled: Boolean): ByteArray {
|
||||
val value: Byte = if (enabled) 0x01 else 0x00
|
||||
return frame(CMD_MISC_SETTING, SUB_CTCSS_SETTING, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set CTCSS tone frequency (CMD 0x1B sub 0x00).
|
||||
*/
|
||||
fun buildSetCtcssToneCommand(toneHz: Double): ByteArray {
|
||||
val bcd = encodeCtcssToneBcd(toneHz)
|
||||
return frame(CMD_CTCSS_TONE, 0x00, *bcd)
|
||||
}
|
||||
|
||||
// ── Response parsing ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find and parse a complete CI-V response frame from a buffer.
|
||||
*
|
||||
* Returns the bytes between "FE FE E0 A4 <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
|
||||
}
|
||||
|
||||
/** 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
|
||||
)
|
||||
}
|
||||
+403
-204
@@ -19,8 +19,10 @@ package com.rtbishop.look4sat.core.data.framework
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.util.Log
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
@@ -29,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -44,6 +47,8 @@ class RadioTrackingService(
|
||||
) : IRadioTrackingService {
|
||||
|
||||
private val tag = "RadioTracking"
|
||||
/** Delay between each step of the split-mode init sequence (ms). */
|
||||
private val INIT_STEP_DELAY_MS = 200L
|
||||
private val _state = MutableStateFlow(RadioTrackingState())
|
||||
override val state: StateFlow<RadioTrackingState> = _state
|
||||
|
||||
@@ -51,227 +56,427 @@ class RadioTrackingService(
|
||||
private var rxController: IRadioController? = null
|
||||
private var trackingJob: Job? = null
|
||||
|
||||
// ── Connection ──────────────────────────────────────────────────────────
|
||||
|
||||
override suspend fun connectRadios() {
|
||||
// Disconnect old controllers if any
|
||||
txController?.disconnect()
|
||||
rxController?.disconnect()
|
||||
|
||||
// Read current addresses from settings
|
||||
val rcSettings = settingsRepo.radioControlSettings.value
|
||||
val txAddr = rcSettings.txRadioAddress
|
||||
val rxAddr = rcSettings.rxRadioAddress
|
||||
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 (currentCoroutineContext().isActive) {
|
||||
val currentState = _state.value
|
||||
if (!currentState.isActive) break
|
||||
|
||||
val satPass = currentState.currentPass ?: break
|
||||
val xpdr = currentState.selectedTransponder ?: break
|
||||
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||
val txNow = txController
|
||||
val rxNow = rxController
|
||||
val v = pos.distanceRate * 1000.0
|
||||
|
||||
if (tuningRadio.isNotEmpty()) {
|
||||
val radio = if (tuningRadio == "tx") txNow else rxNow
|
||||
if (radio != null && radio.isConnected) {
|
||||
val read = radio.readFrequencyAndMode()
|
||||
if (read != null) {
|
||||
val (freq, _) = read
|
||||
if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
|
||||
else { stableCount = 0; lastReadFreq = freq }
|
||||
if (stableCount >= 2) {
|
||||
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||
val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||
if (newBase > 0) {
|
||||
txBaseFreq = newBase
|
||||
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||
Log.i(tag, "TX tuning done → base=$newBase")
|
||||
}
|
||||
} else if (tuningRadio == "rx") {
|
||||
val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||
if (newTxBase != null && newTxBase > 0) {
|
||||
txBaseFreq = newTxBase
|
||||
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||
Log.i(tag, "RX tuning done → txBase=$newTxBase")
|
||||
}
|
||||
}
|
||||
tuningRadio = ""
|
||||
stableCount = 0
|
||||
lastSetTxFreq = 0.0
|
||||
lastSetRxFreq = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 ──────────────────────────────────────────
|
||||
// Sequence per IC-705: explicitly select VFO, then band → freq → mode.
|
||||
// ACK from each command gates the next — no fixed delays needed.
|
||||
|
||||
// VFO-A = RX (downlink)
|
||||
Log.d(tag, "Split init: selecting VFO-A for RX (downlink)")
|
||||
radio.setVfo(vfoA = true)
|
||||
if (rxNominal != null) {
|
||||
Log.d(tag, "Split init: VFO-A band for ${rxNominal}Hz")
|
||||
radio.setBand(rxNominal)
|
||||
Log.d(tag, "Split init: VFO-A freq=${rxNominal}Hz")
|
||||
radio.setFrequency(rxNominal)
|
||||
}
|
||||
if (rxMode != null) {
|
||||
Log.d(tag, "Split init: VFO-A mode=$rxMode")
|
||||
radio.setMode(rxMode)
|
||||
}
|
||||
|
||||
// VFO-B = TX (uplink)
|
||||
Log.d(tag, "Split init: selecting VFO-B for TX (uplink)")
|
||||
radio.setVfo(vfoA = false)
|
||||
if (txBase != null) {
|
||||
Log.d(tag, "Split init: VFO-B band for ${txBase}Hz")
|
||||
radio.setBand(txBase)
|
||||
Log.d(tag, "Split init: VFO-B freq=${txBase}Hz")
|
||||
radio.setFrequency(txBase)
|
||||
}
|
||||
if (txMode != null) {
|
||||
Log.d(tag, "Split init: VFO-B mode=$txMode")
|
||||
radio.setMode(txMode)
|
||||
}
|
||||
if (txMode?.uppercase() == "FM") {
|
||||
val tone = _state.value.ctcssTone
|
||||
if (tone != null) {
|
||||
Log.d(tag, "Split init: CTCSS=${tone}Hz")
|
||||
radio.setCtcssTone(tone)
|
||||
radio.setCtcssMode(true)
|
||||
} else {
|
||||
radio.setCtcssMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Enable SPLIT on VFO-A (return display to RX VFO first)
|
||||
Log.d(tag, "Split init: returning to VFO-A, then enabling SPLIT mode")
|
||||
radio.setVfo(vfoA = true)
|
||||
radio.setSplitMode(enabled = true)
|
||||
|
||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode, txBaseFrequencyHz = txBase) }
|
||||
Log.i(tag, "IC-705 split init done — entering tracking loop")
|
||||
|
||||
// ── Tracking loop with tuning detection ─────────────────────────────
|
||||
var lastSetTxFreq = 0.0
|
||||
var lastSetRxFreq = 0.0
|
||||
var tuningRadio = "" // "tx" or "rx" when manual tuning detected
|
||||
var lastReadFreq = 0L
|
||||
var stableCount = 0
|
||||
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val currentState = _state.value
|
||||
if (!currentState.isActive) break
|
||||
|
||||
val satPass = currentState.currentPass ?: break
|
||||
val xpdr = currentState.selectedTransponder ?: break
|
||||
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||
val stationPos = settingsRepo.stationPosition.value
|
||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||
val v = pos.distanceRate * 1000.0
|
||||
|
||||
if (tuningRadio.isNotEmpty()) {
|
||||
// User is tuning — wait for frequency to stabilize
|
||||
val readFreq = if (tuningRadio == "tx") radio.readTxVfoFrequency() else radio.readWorkingFrequency()
|
||||
if (readFreq != null) {
|
||||
if (kotlin.math.abs(readFreq - lastReadFreq) <= 20) stableCount++
|
||||
else { stableCount = 0; lastReadFreq = readFreq }
|
||||
|
||||
if (stableCount >= 2) {
|
||||
// Frequency stable — reverse-calculate base frequency
|
||||
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||
val newBase = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||
if (newBase > 0) {
|
||||
txBaseFreq = newBase
|
||||
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||
Log.i(tag, "Split TX tuning done → base=$newBase")
|
||||
}
|
||||
} else if (tuningRadio == "rx") {
|
||||
val rxNominal = (readFreq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||
if (newTxBase != null && newTxBase > 0) {
|
||||
txBaseFreq = newTxBase
|
||||
_state.update { it.copy(txBaseFrequencyHz = newTxBase) }
|
||||
Log.i(tag, "Split RX tuning done → txBase=$newTxBase")
|
||||
}
|
||||
}
|
||||
tuningRadio = ""
|
||||
stableCount = 0
|
||||
lastSetTxFreq = 0.0
|
||||
lastSetRxFreq = 0.0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Detect manual dial changes
|
||||
if (txBaseFreq != null && lastSetTxFreq > 0.0) {
|
||||
val readTx = radio.readTxVfoFrequency()
|
||||
if (readTx != null && kotlin.math.abs(readTx - lastSetTxFreq) >= 20.0) {
|
||||
tuningRadio = "tx"
|
||||
lastReadFreq = readTx
|
||||
stableCount = 0
|
||||
Log.i(tag, "Split TX tuning detected (read=${readTx}, lastSet=$lastSetTxFreq)")
|
||||
}
|
||||
}
|
||||
if (tuningRadio.isEmpty() && lastSetRxFreq > 0.0) {
|
||||
val readRx = radio.readWorkingFrequency()
|
||||
if (readRx != null && kotlin.math.abs(readRx - lastSetRxFreq) >= 20.0) {
|
||||
tuningRadio = "rx"
|
||||
lastReadFreq = readRx
|
||||
stableCount = 0
|
||||
Log.i(tag, "Split RX tuning detected (read=${readRx}, lastSet=$lastSetRxFreq)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine Doppler-corrected frequencies
|
||||
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||
val rxBaseCalc = if (txBaseFreq != null) {
|
||||
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||
} else xpdr.downlinkLow
|
||||
val rxRadioFreq = rxBaseCalc?.let { pos.getDownlinkFreq(it) }
|
||||
|
||||
if (radio.isConnected && tuningRadio.isEmpty()) {
|
||||
// Update both VFOs every cycle — no PTT polling needed.
|
||||
// 0x25/00 = active (RX) VFO, 0x25/01 = inactive (TX) VFO.
|
||||
if (rxRadioFreq != null) {
|
||||
Log.d(tag, "Split loop RX (0x25/00): ${rxRadioFreq}Hz")
|
||||
radio.setWorkingFrequency(rxRadioFreq)
|
||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||
}
|
||||
if (txRadioFreq != null) {
|
||||
Log.d(tag, "Split loop TX (0x25/01): ${txRadioFreq}Hz")
|
||||
radio.setTxVfoFrequency(txRadioFreq)
|
||||
lastSetTxFreq = txRadioFreq.toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
txConnected = radio.isConnected,
|
||||
rxConnected = false, // single radio
|
||||
txFrequencyHz = txRadioFreq,
|
||||
rxFrequencyHz = rxRadioFreq,
|
||||
azimuth = Math.toDegrees(pos.azimuth),
|
||||
elevation = Math.toDegrees(pos.elevation),
|
||||
distance = pos.distance
|
||||
)
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Other IRadioTrackingService methods ─────────────────────────────────
|
||||
|
||||
override fun stopTracking() {
|
||||
trackingJob?.cancel()
|
||||
trackingJob = null
|
||||
@@ -288,7 +493,6 @@ class RadioTrackingService(
|
||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||
}
|
||||
rxMode?.let { rx?.setMode(it) }
|
||||
|
||||
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||
_state.value.ctcssTone?.let { tone ->
|
||||
tx?.setCtcssTone(tone)
|
||||
@@ -302,21 +506,17 @@ class RadioTrackingService(
|
||||
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||
else -> null
|
||||
}
|
||||
// Show nominal frequencies immediately
|
||||
val rxNominal = if (txCenter != null) {
|
||||
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||
} else {
|
||||
// Downlink-only transponder (beacon etc.) - use downlink directly
|
||||
transponder.downlinkLow
|
||||
}
|
||||
} else transponder.downlinkLow
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedTransponder = transponder,
|
||||
txBaseFrequencyHz = txCenter,
|
||||
txFrequencyHz = txCenter,
|
||||
rxFrequencyHz = rxNominal,
|
||||
txMode = transponder.uplinkMode,
|
||||
rxMode = transponder.downlinkMode
|
||||
txBaseFrequencyHz = txCenter,
|
||||
txFrequencyHz = txCenter,
|
||||
rxFrequencyHz = rxNominal,
|
||||
txMode = transponder.uplinkMode,
|
||||
rxMode = transponder.downlinkMode
|
||||
?: transponder.uplinkMode?.let { m ->
|
||||
TransponderMapper.mapUplinkModeToDownlinkMode(m, transponder.isInverted)
|
||||
}
|
||||
@@ -353,5 +553,4 @@ class RadioTrackingService(
|
||||
}
|
||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> = _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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+47
@@ -38,4 +38,51 @@ interface IRadioController {
|
||||
suspend fun pttOn(): Boolean
|
||||
|
||||
suspend fun pttOff(): Boolean
|
||||
|
||||
// ── Extended operations (IC-705 / CI-V) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Select the band matching [frequencyHz] via the band stacking register.
|
||||
* Must be called before [setFrequency] and [setMode] when first tracking.
|
||||
* Default: no-op (Yaesu radios auto-switch band via frequency).
|
||||
*/
|
||||
suspend fun setBand(frequencyHz: Long): Boolean = false
|
||||
|
||||
/**
|
||||
* Select the active VFO.
|
||||
* @param vfoA true → VFO-A (main/RX), false → VFO-B (sub/TX in split).
|
||||
*/
|
||||
suspend fun setVfo(vfoA: Boolean): Boolean = false
|
||||
|
||||
/**
|
||||
* Enable or disable SPLIT mode (TX on sub-VFO, RX on main VFO).
|
||||
* Default: not supported.
|
||||
*/
|
||||
suspend fun setSplitMode(enabled: Boolean): Boolean = false
|
||||
|
||||
/**
|
||||
* Set the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
||||
* Default: delegates to [setFrequency].
|
||||
*/
|
||||
suspend fun setWorkingFrequency(frequencyHz: Long): Boolean = setFrequency(frequencyHz)
|
||||
|
||||
/**
|
||||
* Set the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
||||
* Sent every tracking cycle alongside [setWorkingFrequency] in split mode.
|
||||
* Default: delegates to [setWorkingFrequency].
|
||||
*/
|
||||
suspend fun setTxVfoFrequency(frequencyHz: Long): Boolean = setWorkingFrequency(frequencyHz)
|
||||
|
||||
/**
|
||||
* Read the frequency of the currently active VFO (IC-705: CMD 0x25 sub 0x00).
|
||||
* Default: delegates to [readFrequencyAndMode].
|
||||
*/
|
||||
suspend fun readWorkingFrequency(): Long? = readFrequencyAndMode()?.first
|
||||
|
||||
/**
|
||||
* Read the frequency of the inactive/TX VFO (IC-705: CMD 0x25 sub 0x01).
|
||||
* Used for tuning detection in split mode.
|
||||
* Default: delegates to [readWorkingFrequency].
|
||||
*/
|
||||
suspend fun readTxVfoFrequency(): Long? = readWorkingFrequency()
|
||||
}
|
||||
+125
-75
@@ -22,11 +22,14 @@ import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
@@ -38,6 +41,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -438,114 +442,164 @@ private fun OutputChannelSection(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RadioControlDialog(
|
||||
initialSettings: RadioControlSettings,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (RadioControlSettings) -> Unit
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val padding = LocalSpacing.current.large
|
||||
val baudRates = listOf(4800, 9600, 38400)
|
||||
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val padding = LocalSpacing.current.large
|
||||
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
||||
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
||||
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
||||
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
||||
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
||||
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
|
||||
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
||||
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
|
||||
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
||||
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
||||
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
||||
val rxName = rememberSaveable { mutableStateOf(initialSettings.rxRadioName) }
|
||||
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
||||
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
||||
|
||||
val pairedDevices = remember {
|
||||
try {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList()
|
||||
} catch (_: SecurityException) {
|
||||
emptyList()
|
||||
val isIcom = radioModel.value == RadioControlSettings.MODEL_ICOM_IC705
|
||||
val isSingleRadio = isIcom && splitMode.value
|
||||
|
||||
// Reset split mode when switching away from IC-705
|
||||
if (!isIcom && splitMode.value) splitMode.value = false
|
||||
|
||||
val baudRates = if (isIcom) RadioControlSettings.BAUD_RATES_ICOM
|
||||
else RadioControlSettings.BAUD_RATES_YAESU
|
||||
|
||||
// If current baud rate is not in the new list, default to the first available
|
||||
if (baudRate.intValue !in baudRates) baudRate.intValue = baudRates.first()
|
||||
|
||||
val pairedDevices: List<Pair<String, String>> = remember {
|
||||
buildList {
|
||||
try {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
manager.adapter?.bondedDevices?.forEach {
|
||||
add(Pair(it.name ?: "Unknown", it.address ?: ""))
|
||||
}
|
||||
} catch (_: SecurityException) { }
|
||||
}
|
||||
}
|
||||
|
||||
val onAccept = {
|
||||
onSave(
|
||||
RadioControlSettings(
|
||||
enabled = enabled.value,
|
||||
radioModel = radioModel.value,
|
||||
enabled = enabled.value,
|
||||
radioModel = radioModel.value,
|
||||
txRadioAddress = txAddress.value,
|
||||
rxRadioAddress = rxAddress.value,
|
||||
txRadioName = txName.value,
|
||||
rxRadioName = rxName.value,
|
||||
baudRate = baudRate.intValue
|
||||
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
|
||||
txRadioName = txName.value,
|
||||
rxRadioName = if (isSingleRadio) "" else rxName.value,
|
||||
baudRate = baudRate.intValue,
|
||||
splitMode = splitMode.value
|
||||
)
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.rc_settings_title),
|
||||
title = stringResource(R.string.rc_settings_title),
|
||||
onCancel = onDismiss,
|
||||
onAccept = onAccept
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = padding)) {
|
||||
|
||||
// Enable switch
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.rc_enable_switch))
|
||||
Switch(checked = enabled.value, onCheckedChange = { enabled.value = it })
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Radio model selection
|
||||
// Radio model — FlowRow so chips wrap on small screens
|
||||
Text(
|
||||
stringResource(R.string.rc_radio_model),
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||
text = stringResource(R.string.rc_radio_model),
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
||||
androidx.compose.material3.FilterChip(
|
||||
FilterChip(
|
||||
selected = radioModel.value == model,
|
||||
onClick = { radioModel.value = model },
|
||||
label = { Text(model, fontSize = 12.sp) },
|
||||
enabled = enabled.value
|
||||
onClick = { radioModel.value = model },
|
||||
label = { Text(model, fontSize = 12.sp) },
|
||||
enabled = enabled.value
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// TX Radio selection
|
||||
Text("TX Radio (Uplink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
||||
// IC-705 split-mode toggle (only shown for IC-705)
|
||||
if (isIcom) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Split mode (single radio)", fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
text = "Use VFO-A/B split on one IC-705 instead of two radios",
|
||||
fontSize = 12.sp,
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = splitMode.value,
|
||||
onCheckedChange = { splitMode.value = it },
|
||||
enabled = enabled.value
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
|
||||
// TX Radio (always shown; in split mode this is the single IC-705)
|
||||
val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)"
|
||||
Text(txLabel, fontWeight = FontWeight.Medium)
|
||||
if (txAddress.value.isNotBlank()) {
|
||||
Text("${txName.value} - ${txAddress.value}", fontSize = 13.sp)
|
||||
Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp)
|
||||
}
|
||||
CardButton(
|
||||
onClick = { selectingFor.value = "tx" },
|
||||
text = "Select TX Device",
|
||||
onClick = { selectingFor.value = "tx" },
|
||||
text = if (isSingleRadio) "Select Device" else "Select TX Device",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// RX Radio selection
|
||||
Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
||||
if (rxAddress.value.isNotBlank()) {
|
||||
Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp)
|
||||
}
|
||||
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 +608,17 @@ fun RadioControlDialog(
|
||||
.clickable {
|
||||
if (selectingFor.value == "tx") {
|
||||
txAddress.value = address
|
||||
txName.value = name
|
||||
txName.value = name
|
||||
} else {
|
||||
rxAddress.value = address
|
||||
rxName.value = name
|
||||
rxName.value = name
|
||||
}
|
||||
selectingFor.value = ""
|
||||
}
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(name, modifier = Modifier.weight(1f))
|
||||
@@ -573,23 +627,19 @@ fun RadioControlDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Baud Rate:")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
baudRates.forEach { rate ->
|
||||
CardButton(
|
||||
onClick = { baudRate.intValue = rate },
|
||||
text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(),
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
// Baud rate — FlowRow so all chips fit on narrow screens
|
||||
Text("Baud Rate:", fontWeight = FontWeight.Medium)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
baudRates.forEach { rate ->
|
||||
FilterChip(
|
||||
selected = rate == baudRate.intValue,
|
||||
onClick = { baudRate.intValue = rate },
|
||||
label = { Text(rate.toString(), fontSize = 12.sp) },
|
||||
enabled = enabled.value
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user