mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Added support for Icom IC-705 CAT (#229)
This commit is contained in:
8 files changed
+1165
-130
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
|
||||||
|
)
|
||||||
|
}
|
||||||
+281
-82
@@ -19,8 +19,10 @@ package com.rtbishop.look4sat.core.data.framework
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
@@ -29,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -44,6 +47,8 @@ class RadioTrackingService(
|
|||||||
) : IRadioTrackingService {
|
) : IRadioTrackingService {
|
||||||
|
|
||||||
private val tag = "RadioTracking"
|
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())
|
private val _state = MutableStateFlow(RadioTrackingState())
|
||||||
override val state: StateFlow<RadioTrackingState> = _state
|
override val state: StateFlow<RadioTrackingState> = _state
|
||||||
|
|
||||||
@@ -51,28 +56,48 @@ class RadioTrackingService(
|
|||||||
private var rxController: IRadioController? = null
|
private var rxController: IRadioController? = null
|
||||||
private var trackingJob: Job? = null
|
private var trackingJob: Job? = null
|
||||||
|
|
||||||
|
// ── Connection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
override suspend fun connectRadios() {
|
override suspend fun connectRadios() {
|
||||||
// Disconnect old controllers if any
|
|
||||||
txController?.disconnect()
|
txController?.disconnect()
|
||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
|
|
||||||
// Read current addresses from settings
|
|
||||||
val rcSettings = settingsRepo.radioControlSettings.value
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
val txAddr = rcSettings.txRadioAddress
|
val txAddr = rcSettings.txRadioAddress
|
||||||
val rxAddr = rcSettings.rxRadioAddress
|
val rxAddr = rcSettings.rxRadioAddress
|
||||||
|
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
|
val isSplit = isIcom && rcSettings.splitMode
|
||||||
|
|
||||||
Log.i(tag, "Connecting TX=$txAddr RX=$rxAddr")
|
Log.i(tag, "connectRadios model=${rcSettings.radioModel} split=$isSplit TX=$txAddr RX=$rxAddr")
|
||||||
|
|
||||||
|
if (isSplit) {
|
||||||
|
// Single-radio split mode: only TX slot is used
|
||||||
|
if (txAddr.isBlank()) {
|
||||||
|
_state.update { it.copy(errorMessage = "No radio address configured in Settings") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val tx = makeController(isIcom, txAddr)
|
||||||
|
txController = tx
|
||||||
|
rxController = null
|
||||||
|
_state.update { it.copy(errorMessage = null) }
|
||||||
|
val txOk = tx.connect()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
txConnected = txOk,
|
||||||
|
rxConnected = false,
|
||||||
|
errorMessage = if (!txOk) "Could not connect to radio ($txAddr)" else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.i(tag, "IC-705 split mode connected: txOk=$txOk")
|
||||||
|
} else {
|
||||||
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
if (txAddr.isBlank() && rxAddr.isBlank()) {
|
||||||
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
_state.update { it.copy(errorMessage = "No radio addresses configured in Settings") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val tx = makeController(isIcom, txAddr)
|
||||||
val tx = Ft817Controller(bluetoothManager, txAddr)
|
val rx = makeController(isIcom, rxAddr)
|
||||||
val rx = Ft817Controller(bluetoothManager, rxAddr)
|
|
||||||
txController = tx
|
txController = tx
|
||||||
rxController = rx
|
rxController = rx
|
||||||
|
|
||||||
_state.update { it.copy(errorMessage = null) }
|
_state.update { it.copy(errorMessage = null) }
|
||||||
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
val txOk = if (txAddr.isNotBlank()) tx.connect() else false
|
||||||
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
val rxOk = if (rxAddr.isNotBlank()) rx.connect() else false
|
||||||
@@ -88,7 +113,13 @@ class RadioTrackingService(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Log.i(tag, "Dual-radio connected: txOk=$txOk rxOk=$rxOk")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeController(isIcom: Boolean, address: String): IRadioController =
|
||||||
|
if (isIcom) Ic705Controller(bluetoothManager, address)
|
||||||
|
else Ft817Controller(bluetoothManager, address)
|
||||||
|
|
||||||
override suspend fun disconnectRadios() {
|
override suspend fun disconnectRadios() {
|
||||||
stopTracking()
|
stopTracking()
|
||||||
@@ -96,15 +127,11 @@ class RadioTrackingService(
|
|||||||
rxController?.disconnect()
|
rxController?.disconnect()
|
||||||
txController = null
|
txController = null
|
||||||
rxController = null
|
rxController = null
|
||||||
_state.update {
|
_state.update { it.copy(txConnected = false, rxConnected = false, isActive = false) }
|
||||||
it.copy(
|
|
||||||
txConnected = false,
|
|
||||||
rxConnected = false,
|
|
||||||
isActive = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tracking ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
override fun startTracking(pass: OrbitalPass, transponder: SatRadio, txBaseFreqHz: Long?) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -115,26 +142,44 @@ class RadioTrackingService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = appScope.launch {
|
|
||||||
// Set modes on both radios at tracking start
|
val rcSettings = settingsRepo.radioControlSettings.value
|
||||||
|
val isIcom = rcSettings.radioModel == RadioControlSettings.MODEL_ICOM_IC705
|
||||||
|
val isSplit = isIcom && rcSettings.splitMode
|
||||||
|
|
||||||
|
if (isSplit) {
|
||||||
|
trackingJob = appScope.launch { runSplitTracking(transponder, txBaseFreqHz) }
|
||||||
|
} else {
|
||||||
|
trackingJob = appScope.launch { runDualRadioTracking(transponder, txBaseFreqHz) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dual-radio tracking (Yaesu or two IC-705s) ──────────────────────────
|
||||||
|
|
||||||
|
private suspend fun runDualRadioTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
||||||
val tx = txController
|
val tx = txController
|
||||||
val rx = rxController
|
val rx = rxController
|
||||||
|
|
||||||
|
// Initial setup: set band/mode/CTCSS on both radios
|
||||||
val txMode = transponder.uplinkMode
|
val txMode = transponder.uplinkMode
|
||||||
val rxMode = transponder.downlinkMode
|
val rxMode = transponder.downlinkMode
|
||||||
?: transponder.uplinkMode?.let {
|
?: transponder.uplinkMode?.let {
|
||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.i(tag, "DualRadio start: txMode=$txMode rxMode=$rxMode")
|
||||||
|
|
||||||
if (tx != null && tx.isConnected && txMode != null) {
|
if (tx != null && tx.isConnected && txMode != null) {
|
||||||
|
Log.d(tag, "Setting TX mode: $txMode")
|
||||||
tx.setMode(txMode)
|
tx.setMode(txMode)
|
||||||
Log.i(tag, "TX mode set to $txMode")
|
|
||||||
}
|
}
|
||||||
if (rx != null && rx.isConnected && rxMode != null) {
|
if (rx != null && rx.isConnected && rxMode != null) {
|
||||||
|
Log.d(tag, "Setting RX mode: $rxMode")
|
||||||
rx.setMode(rxMode)
|
rx.setMode(rxMode)
|
||||||
Log.i(tag, "RX mode set to $rxMode")
|
|
||||||
}
|
}
|
||||||
// Set CTCSS if FM
|
|
||||||
if (txMode?.uppercase() == "FM") {
|
if (txMode?.uppercase() == "FM") {
|
||||||
_state.value.ctcssTone?.let { tone ->
|
_state.value.ctcssTone?.let { tone ->
|
||||||
|
Log.d(tag, "Setting CTCSS: ${tone}Hz")
|
||||||
tx?.setCtcssTone(tone)
|
tx?.setCtcssTone(tone)
|
||||||
tx?.setCtcssMode(true)
|
tx?.setCtcssMode(true)
|
||||||
}
|
}
|
||||||
@@ -143,11 +188,11 @@ class RadioTrackingService(
|
|||||||
|
|
||||||
var lastSetTxFreq = 0.0
|
var lastSetTxFreq = 0.0
|
||||||
var lastSetRxFreq = 0.0
|
var lastSetRxFreq = 0.0
|
||||||
var tuningRadio = "" // "", "tx", or "rx" - which radio the user is tuning
|
var tuningRadio = ""
|
||||||
var lastReadFreq = 0L
|
var lastReadFreq = 0L
|
||||||
var stableCount = 0
|
var stableCount = 0
|
||||||
|
|
||||||
while (isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
val currentState = _state.value
|
val currentState = _state.value
|
||||||
if (!currentState.isActive) break
|
if (!currentState.isActive) break
|
||||||
|
|
||||||
@@ -155,39 +200,29 @@ class RadioTrackingService(
|
|||||||
val xpdr = currentState.selectedTransponder ?: break
|
val xpdr = currentState.selectedTransponder ?: break
|
||||||
var txBaseFreq = currentState.txBaseFrequencyHz
|
var txBaseFreq = currentState.txBaseFrequencyHz
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val timeNow = System.currentTimeMillis()
|
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, System.currentTimeMillis())
|
||||||
|
val txNow = txController
|
||||||
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
|
val rxNow = rxController
|
||||||
val tx = txController
|
|
||||||
val rx = rxController
|
|
||||||
val hasUplink = txBaseFreq != null
|
|
||||||
val c = com.rtbishop.look4sat.core.domain.predict.SPEED_OF_LIGHT
|
|
||||||
val v = pos.distanceRate * 1000.0
|
val v = pos.distanceRate * 1000.0
|
||||||
|
|
||||||
if (tuningRadio.isNotEmpty()) {
|
if (tuningRadio.isNotEmpty()) {
|
||||||
// --- User is tuning: keep reading, wait for stabilization ---
|
val radio = if (tuningRadio == "tx") txNow else rxNow
|
||||||
val radio = if (tuningRadio == "tx") tx else rx
|
|
||||||
if (radio != null && radio.isConnected) {
|
if (radio != null && radio.isConnected) {
|
||||||
val readResult = radio.readFrequencyAndMode()
|
val read = radio.readFrequencyAndMode()
|
||||||
if (readResult != null) {
|
if (read != null) {
|
||||||
val (freq, _) = readResult
|
val (freq, _) = read
|
||||||
if (kotlin.math.abs(freq - lastReadFreq) <= 20) {
|
if (kotlin.math.abs(freq - lastReadFreq) <= 20) stableCount++
|
||||||
stableCount++
|
else { stableCount = 0; lastReadFreq = freq }
|
||||||
} else {
|
|
||||||
stableCount = 0
|
|
||||||
lastReadFreq = freq
|
|
||||||
}
|
|
||||||
// Stable for 2 reads → user stopped turning
|
|
||||||
if (stableCount >= 2) {
|
if (stableCount >= 2) {
|
||||||
if (tuningRadio == "tx" && txBaseFreq != null) {
|
if (tuningRadio == "tx" && txBaseFreq != null) {
|
||||||
val newBase = (freq.toDouble() * c / (c + v)).toLong()
|
val newBase = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT + v)).toLong()
|
||||||
if (newBase > 0) {
|
if (newBase > 0) {
|
||||||
txBaseFreq = newBase
|
txBaseFreq = newBase
|
||||||
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
_state.update { it.copy(txBaseFrequencyHz = newBase) }
|
||||||
Log.i(tag, "TX tuning done → base=$newBase")
|
Log.i(tag, "TX tuning done → base=$newBase")
|
||||||
}
|
}
|
||||||
} else if (tuningRadio == "rx") {
|
} else if (tuningRadio == "rx") {
|
||||||
val rxNominal = (freq.toDouble() * c / (c - v)).toLong()
|
val rxNominal = (freq.toDouble() * SPEED_OF_LIGHT / (SPEED_OF_LIGHT - v)).toLong()
|
||||||
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
val newTxBase = TransponderMapper.mapDownlinkToUplink(rxNominal, xpdr)
|
||||||
if (newTxBase != null && newTxBase > 0) {
|
if (newTxBase != null && newTxBase > 0) {
|
||||||
txBaseFreq = newTxBase
|
txBaseFreq = newTxBase
|
||||||
@@ -203,62 +238,48 @@ class RadioTrackingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// --- Normal tracking: read, detect changes, command ---
|
// Detect manual dial changes
|
||||||
|
if (txBaseFreq != null && txNow != null && txNow.isConnected && lastSetTxFreq > 0.0) {
|
||||||
// TX dial feedback
|
val read = txNow.readFrequencyAndMode()
|
||||||
if (hasUplink && tx != null && tx.isConnected && lastSetTxFreq > 0.0) {
|
if (read != null && kotlin.math.abs(read.first - lastSetTxFreq) >= 20.0) {
|
||||||
val readResult = tx.readFrequencyAndMode()
|
|
||||||
if (readResult != null) {
|
|
||||||
val (actualTxFreq, _) = readResult
|
|
||||||
if (kotlin.math.abs(actualTxFreq - lastSetTxFreq) >= 20.0) {
|
|
||||||
tuningRadio = "tx"
|
tuningRadio = "tx"
|
||||||
lastReadFreq = actualTxFreq
|
lastReadFreq = read.first
|
||||||
stableCount = 0
|
stableCount = 0
|
||||||
Log.i(tag, "TX tuning detected (read=$actualTxFreq, lastSet=$lastSetTxFreq)")
|
Log.i(tag, "TX tuning detected (read=${read.first}, lastSet=$lastSetTxFreq)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (tuningRadio.isEmpty() && rxNow != null && rxNow.isConnected && lastSetRxFreq > 0.0) {
|
||||||
|
val read = rxNow.readFrequencyAndMode()
|
||||||
// RX dial feedback (only if TX not tuning)
|
if (read != null && kotlin.math.abs(read.first - lastSetRxFreq) >= 20.0) {
|
||||||
if (tuningRadio.isEmpty() && rx != null && rx.isConnected && lastSetRxFreq > 0.0) {
|
|
||||||
val readResult = rx.readFrequencyAndMode()
|
|
||||||
if (readResult != null) {
|
|
||||||
val (actualRxFreq, _) = readResult
|
|
||||||
if (kotlin.math.abs(actualRxFreq - lastSetRxFreq) >= 20.0) {
|
|
||||||
tuningRadio = "rx"
|
tuningRadio = "rx"
|
||||||
lastReadFreq = actualRxFreq
|
lastReadFreq = read.first
|
||||||
stableCount = 0
|
stableCount = 0
|
||||||
Log.i(tag, "RX tuning detected (read=$actualRxFreq, lastSet=$lastSetRxFreq)")
|
Log.i(tag, "RX tuning detected (read=${read.first}, lastSet=$lastSetRxFreq)")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute Doppler-corrected frequencies
|
|
||||||
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
val txRadioFreq = txBaseFreq?.let { pos.getUplinkFreq(it) }
|
||||||
val rxBaseFreq = if (txBaseFreq != null) {
|
val rxBaseFreq = if (txBaseFreq != null) {
|
||||||
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
TransponderMapper.mapUplinkToDownlink(txBaseFreq, xpdr)
|
||||||
} else {
|
} else xpdr.downlinkLow
|
||||||
xpdr.downlinkLow
|
|
||||||
}
|
|
||||||
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
val rxRadioFreq = rxBaseFreq?.let { pos.getDownlinkFreq(it) }
|
||||||
|
|
||||||
// Command radios (only when not tuning)
|
|
||||||
if (tuningRadio.isEmpty()) {
|
if (tuningRadio.isEmpty()) {
|
||||||
if (tx != null && tx.isConnected && txRadioFreq != null) {
|
if (txNow != null && txNow.isConnected && txRadioFreq != null) {
|
||||||
tx.setFrequency(txRadioFreq)
|
txNow.setFrequency(txRadioFreq)
|
||||||
lastSetTxFreq = txRadioFreq.toDouble()
|
lastSetTxFreq = txRadioFreq.toDouble()
|
||||||
}
|
}
|
||||||
if (rx != null && rx.isConnected && rxRadioFreq != null) {
|
if (rxNow != null && rxNow.isConnected && rxRadioFreq != null) {
|
||||||
rx.setFrequency(rxRadioFreq)
|
rxNow.setFrequency(rxRadioFreq)
|
||||||
lastSetRxFreq = rxRadioFreq.toDouble()
|
lastSetRxFreq = rxRadioFreq.toDouble()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
txConnected = tx?.isConnected ?: false,
|
txConnected = txNow?.isConnected ?: false,
|
||||||
rxConnected = rx?.isConnected ?: false,
|
rxConnected = rxNow?.isConnected ?: false,
|
||||||
txFrequencyHz = txRadioFreq,
|
txFrequencyHz = txRadioFreq,
|
||||||
rxFrequencyHz = rxRadioFreq,
|
rxFrequencyHz = rxRadioFreq,
|
||||||
azimuth = Math.toDegrees(pos.azimuth),
|
azimuth = Math.toDegrees(pos.azimuth),
|
||||||
@@ -266,12 +287,196 @@ class RadioTrackingService(
|
|||||||
distance = pos.distance
|
distance = pos.distance
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(1000)
|
delay(1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── IC-705 split-radio tracking ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun runSplitTracking(transponder: SatRadio, initialTxBaseFreqHz: Long?) {
|
||||||
|
val radio = txController ?: return
|
||||||
|
if (!radio.isConnected) return
|
||||||
|
|
||||||
|
val txMode = transponder.uplinkMode
|
||||||
|
val rxMode = transponder.downlinkMode
|
||||||
|
?: transponder.uplinkMode?.let {
|
||||||
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute nominal base frequencies
|
||||||
|
val txCenter = when {
|
||||||
|
transponder.uplinkLow != null && transponder.uplinkHigh != null ->
|
||||||
|
(transponder.uplinkLow!! + transponder.uplinkHigh!!) / 2
|
||||||
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val rxNominal = if (txCenter != null) {
|
||||||
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
|
} else transponder.downlinkLow
|
||||||
|
|
||||||
|
val txBase = initialTxBaseFreqHz ?: txCenter
|
||||||
|
Log.i(tag, "IC-705 split setup: txBase=${txBase}Hz rxNominal=${rxNominal}Hz txMode=$txMode rxMode=$rxMode")
|
||||||
|
|
||||||
|
// ── Initial setup sequence ──────────────────────────────────────────
|
||||||
|
// 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() {
|
override fun stopTracking() {
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = null
|
trackingJob = null
|
||||||
@@ -288,7 +493,6 @@ class RadioTrackingService(
|
|||||||
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
TransponderMapper.mapUplinkModeToDownlinkMode(it, transponder.isInverted)
|
||||||
}
|
}
|
||||||
rxMode?.let { rx?.setMode(it) }
|
rxMode?.let { rx?.setMode(it) }
|
||||||
|
|
||||||
if (transponder.uplinkMode?.uppercase() == "FM") {
|
if (transponder.uplinkMode?.uppercase() == "FM") {
|
||||||
_state.value.ctcssTone?.let { tone ->
|
_state.value.ctcssTone?.let { tone ->
|
||||||
tx?.setCtcssTone(tone)
|
tx?.setCtcssTone(tone)
|
||||||
@@ -302,13 +506,9 @@ class RadioTrackingService(
|
|||||||
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
transponder.uplinkLow != null -> transponder.uplinkLow!!
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
// Show nominal frequencies immediately
|
|
||||||
val rxNominal = if (txCenter != null) {
|
val rxNominal = if (txCenter != null) {
|
||||||
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
TransponderMapper.mapUplinkToDownlink(txCenter, transponder)
|
||||||
} else {
|
} else transponder.downlinkLow
|
||||||
// Downlink-only transponder (beacon etc.) - use downlink directly
|
|
||||||
transponder.downlinkLow
|
|
||||||
}
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedTransponder = transponder,
|
selectedTransponder = transponder,
|
||||||
@@ -353,5 +553,4 @@ class RadioTrackingService(
|
|||||||
}
|
}
|
||||||
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
_state.update { it.copy(txMode = txMode, rxMode = rxMode) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,7 @@ import androidx.room.Room
|
|||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||||
|
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
@@ -39,6 +40,7 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
|||||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
@@ -107,14 +109,24 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
|
|
||||||
override fun provideTxRadioController(): IRadioController {
|
override fun provideTxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val address = settingsRepo.radioControlSettings.value.txRadioAddress
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
return Ft817Controller(manager, address)
|
val address = settings.txRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideRxRadioController(): IRadioController {
|
override fun provideRxRadioController(): IRadioController {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val address = settingsRepo.radioControlSettings.value.rxRadioAddress
|
val settings = settingsRepo.radioControlSettings.value
|
||||||
return Ft817Controller(manager, address)
|
val address = settings.rxRadioAddress
|
||||||
|
return if (settings.radioModel == RadioControlSettings.MODEL_ICOM_IC705) {
|
||||||
|
Ic705Controller(manager, address)
|
||||||
|
} else {
|
||||||
|
Ft817Controller(manager, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideSensorsRepo(): ISensorsRepo {
|
override fun provideSensorsRepo(): ISensorsRepo {
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ class SettingsRepo(
|
|||||||
private val keyTxRadioName = "txRadioName"
|
private val keyTxRadioName = "txRadioName"
|
||||||
private val keyRxRadioName = "rxRadioName"
|
private val keyRxRadioName = "rxRadioName"
|
||||||
private val keyRadioBaudRate = "radioBaudRate"
|
private val keyRadioBaudRate = "radioBaudRate"
|
||||||
|
private val keyRadioSplitMode = "radioSplitMode"
|
||||||
|
|
||||||
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
private val _radioControlSettings = MutableStateFlow(getRadioControlSettings())
|
||||||
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
override val radioControlSettings: StateFlow<RadioControlSettings> = _radioControlSettings
|
||||||
@@ -396,18 +397,20 @@ class SettingsRepo(
|
|||||||
putString(keyTxRadioName, settings.txRadioName)
|
putString(keyTxRadioName, settings.txRadioName)
|
||||||
putString(keyRxRadioName, settings.rxRadioName)
|
putString(keyRxRadioName, settings.rxRadioName)
|
||||||
putInt(keyRadioBaudRate, settings.baudRate)
|
putInt(keyRadioBaudRate, settings.baudRate)
|
||||||
|
putBoolean(keyRadioSplitMode, settings.splitMode)
|
||||||
}
|
}
|
||||||
_radioControlSettings.value = settings
|
_radioControlSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
private fun getRadioControlSettings(): RadioControlSettings = RadioControlSettings(
|
||||||
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
enabled = preferences.getBoolean(keyRadioControlEnabled, false),
|
||||||
radioModel = preferences.getString(keyRadioModel, null) ?: "Yaesu FT-817/818",
|
radioModel = preferences.getString(keyRadioModel, null) ?: RadioControlSettings.MODEL_YAESU_FT817,
|
||||||
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
txRadioAddress = preferences.getString(keyTxRadioAddress, null) ?: "",
|
||||||
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
rxRadioAddress = preferences.getString(keyRxRadioAddress, null) ?: "",
|
||||||
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
txRadioName = preferences.getString(keyTxRadioName, null) ?: "TX Radio",
|
||||||
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
rxRadioName = preferences.getString(keyRxRadioName, null) ?: "RX Radio",
|
||||||
baudRate = preferences.getInt(keyRadioBaudRate, 4800)
|
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
|
||||||
|
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
}
|
}
|
||||||
@@ -74,12 +74,20 @@ data class RadioControlSettings(
|
|||||||
val rxRadioAddress: String,
|
val rxRadioAddress: String,
|
||||||
val txRadioName: String,
|
val txRadioName: String,
|
||||||
val rxRadioName: String,
|
val rxRadioName: String,
|
||||||
val baudRate: Int
|
val baudRate: Int,
|
||||||
|
/** IC-705 only: use single-radio split-VFO mode instead of two radios. */
|
||||||
|
val splitMode: Boolean = false
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val SUPPORTED_RADIOS = listOf(
|
const val MODEL_YAESU_FT817 = "Yaesu FT-817/818"
|
||||||
"Yaesu FT-817/818",
|
const val MODEL_YAESU_FT857 = "Yaesu FT-857/897"
|
||||||
"Yaesu FT-857/897"
|
const val MODEL_ICOM_IC705 = "Icom IC-705"
|
||||||
)
|
|
||||||
|
val SUPPORTED_RADIOS = listOf(MODEL_YAESU_FT817, MODEL_YAESU_FT857, MODEL_ICOM_IC705)
|
||||||
|
|
||||||
|
/** Baud rates available for Yaesu radios. */
|
||||||
|
val BAUD_RATES_YAESU = listOf(4800, 9600, 38400)
|
||||||
|
/** Baud rates available for Icom IC-705 (higher speeds supported via CI-V USB/BT). */
|
||||||
|
val BAUD_RATES_ICOM = listOf(4800, 9600, 19200, 38400, 57600, 115200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+47
@@ -38,4 +38,51 @@ interface IRadioController {
|
|||||||
suspend fun pttOn(): Boolean
|
suspend fun pttOn(): Boolean
|
||||||
|
|
||||||
suspend fun pttOff(): 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()
|
||||||
}
|
}
|
||||||
+87
-37
@@ -22,11 +22,14 @@ import android.content.Context
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -38,6 +41,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -438,6 +442,7 @@ private fun OutputChannelSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RadioControlDialog(
|
fun RadioControlDialog(
|
||||||
initialSettings: RadioControlSettings,
|
initialSettings: RadioControlSettings,
|
||||||
@@ -446,9 +451,9 @@ fun RadioControlDialog(
|
|||||||
) {
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val baudRates = listOf(4800, 9600, 38400)
|
|
||||||
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
val enabled = rememberSaveable { mutableStateOf(initialSettings.enabled) }
|
||||||
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
val radioModel = rememberSaveable { mutableStateOf(initialSettings.radioModel) }
|
||||||
|
val splitMode = rememberSaveable { mutableStateOf(initialSettings.splitMode) }
|
||||||
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
val txAddress = rememberSaveable { mutableStateOf(initialSettings.txRadioAddress) }
|
||||||
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
val rxAddress = rememberSaveable { mutableStateOf(initialSettings.rxRadioAddress) }
|
||||||
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
val txName = rememberSaveable { mutableStateOf(initialSettings.txRadioName) }
|
||||||
@@ -456,12 +461,26 @@ fun RadioControlDialog(
|
|||||||
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
val baudRate = rememberSaveable { mutableIntStateOf(initialSettings.baudRate) }
|
||||||
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
val selectingFor = rememberSaveable { mutableStateOf("") } // "tx", "rx", or ""
|
||||||
|
|
||||||
val pairedDevices = remember {
|
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 {
|
try {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
manager.adapter?.bondedDevices?.map { Pair(it.name ?: "Unknown", it.address) } ?: emptyList()
|
manager.adapter?.bondedDevices?.forEach {
|
||||||
} catch (_: SecurityException) {
|
add(Pair(it.name ?: "Unknown", it.address ?: ""))
|
||||||
emptyList()
|
}
|
||||||
|
} catch (_: SecurityException) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,20 +490,24 @@ fun RadioControlDialog(
|
|||||||
enabled = enabled.value,
|
enabled = enabled.value,
|
||||||
radioModel = radioModel.value,
|
radioModel = radioModel.value,
|
||||||
txRadioAddress = txAddress.value,
|
txRadioAddress = txAddress.value,
|
||||||
rxRadioAddress = rxAddress.value,
|
rxRadioAddress = if (isSingleRadio) "" else rxAddress.value,
|
||||||
txRadioName = txName.value,
|
txRadioName = txName.value,
|
||||||
rxRadioName = rxName.value,
|
rxRadioName = if (isSingleRadio) "" else rxName.value,
|
||||||
baudRate = baudRate.intValue
|
baudRate = baudRate.intValue,
|
||||||
|
splitMode = splitMode.value
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
SharedDialog(
|
SharedDialog(
|
||||||
title = stringResource(R.string.rc_settings_title),
|
title = stringResource(R.string.rc_settings_title),
|
||||||
onCancel = onDismiss,
|
onCancel = onDismiss,
|
||||||
onAccept = onAccept
|
onAccept = onAccept
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = padding)) {
|
Column(modifier = Modifier.padding(horizontal = padding)) {
|
||||||
|
|
||||||
|
// Enable switch
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -495,15 +518,15 @@ fun RadioControlDialog(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// Radio model selection
|
// Radio model — FlowRow so chips wrap on small screens
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.rc_radio_model),
|
text = stringResource(R.string.rc_radio_model),
|
||||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
RadioControlSettings.SUPPORTED_RADIOS.forEach { model ->
|
||||||
androidx.compose.material3.FilterChip(
|
FilterChip(
|
||||||
selected = radioModel.value == model,
|
selected = radioModel.value == model,
|
||||||
onClick = { radioModel.value = model },
|
onClick = { radioModel.value = model },
|
||||||
label = { Text(model, fontSize = 12.sp) },
|
label = { Text(model, fontSize = 12.sp) },
|
||||||
@@ -513,39 +536,70 @@ fun RadioControlDialog(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// TX Radio selection
|
// IC-705 split-mode toggle (only shown for IC-705)
|
||||||
Text("TX Radio (Uplink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
if (isIcom) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Split mode (single radio)", fontWeight = FontWeight.Medium)
|
||||||
|
Text(
|
||||||
|
text = "Use VFO-A/B split on one IC-705 instead of two radios",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = splitMode.value,
|
||||||
|
onCheckedChange = { splitMode.value = it },
|
||||||
|
enabled = enabled.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TX Radio (always shown; in split mode this is the single IC-705)
|
||||||
|
val txLabel = if (isSingleRadio) "Radio (IC-705)" else "TX Radio (Uplink)"
|
||||||
|
Text(txLabel, fontWeight = FontWeight.Medium)
|
||||||
if (txAddress.value.isNotBlank()) {
|
if (txAddress.value.isNotBlank()) {
|
||||||
Text("${txName.value} - ${txAddress.value}", fontSize = 13.sp)
|
Text("${txName.value} — ${txAddress.value}", fontSize = 13.sp)
|
||||||
}
|
}
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { selectingFor.value = "tx" },
|
onClick = { selectingFor.value = "tx" },
|
||||||
text = "Select TX Device",
|
text = if (isSingleRadio) "Select Device" else "Select TX Device",
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
// RX Radio selection
|
// RX Radio (hidden in split mode — the same radio handles both)
|
||||||
Text("RX Radio (Downlink)", fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
if (!isSingleRadio) {
|
||||||
|
Text("RX Radio (Downlink)", fontWeight = FontWeight.Medium)
|
||||||
if (rxAddress.value.isNotBlank()) {
|
if (rxAddress.value.isNotBlank()) {
|
||||||
Text("${rxName.value} - ${rxAddress.value}", fontSize = 13.sp)
|
Text("${rxName.value} — ${rxAddress.value}", fontSize = 13.sp)
|
||||||
}
|
}
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { selectingFor.value = "rx" },
|
onClick = { selectingFor.value = "rx" },
|
||||||
text = "Select RX Device",
|
text = "Select RX Device",
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Paired devices list (shown when selecting)
|
|
||||||
if (selectingFor.value.isNotBlank()) {
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paired device picker (inline, shown while selecting)
|
||||||
|
if (selectingFor.value.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Paired Bluetooth Devices:",
|
text = "Paired Bluetooth Devices:",
|
||||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
color = androidx.compose.material3.MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
if (pairedDevices.isEmpty()) {
|
if (pairedDevices.isEmpty()) {
|
||||||
Text("No paired devices found. Pair your BT adapter in Android Bluetooth settings first.")
|
Text(
|
||||||
|
"No paired devices found. Pair your BT adapter in Android Bluetooth settings first.",
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
pairedDevices.forEach { (name, address) ->
|
pairedDevices.forEach { (name, address) ->
|
||||||
androidx.compose.material3.Surface(
|
androidx.compose.material3.Surface(
|
||||||
@@ -573,25 +627,21 @@ fun RadioControlDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
// Baud rate — FlowRow so all chips fit on narrow screens
|
||||||
Row(
|
Text("Baud Rate:", fontWeight = FontWeight.Medium)
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text("Baud Rate:")
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
baudRates.forEach { rate ->
|
baudRates.forEach { rate ->
|
||||||
CardButton(
|
FilterChip(
|
||||||
|
selected = rate == baudRate.intValue,
|
||||||
onClick = { baudRate.intValue = rate },
|
onClick = { baudRate.intValue = rate },
|
||||||
text = if (rate == baudRate.intValue) "[$rate]" else rate.toString(),
|
label = { Text(rate.toString(), fontSize = 12.sp) },
|
||||||
modifier = Modifier
|
enabled = enabled.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Reference in new issue
Block a user