Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions android/app/src/main/java/com/js8call/example/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ class MainActivity : AppCompatActivity() {
val driftMs = intent.getLongExtra(JS8EngineService.EXTRA_TIME_DRIFT_MS, 0L)
monitorViewModel.updateTimeDrift(driftMs)
}
JS8EngineService.ACTION_RIG_STATUS -> {
val connected = intent.getBooleanExtra(JS8EngineService.EXTRA_RIG_CONNECTED, false)
monitorViewModel.updateRigConnected(connected)
}
}
}
}
Expand Down Expand Up @@ -218,6 +222,7 @@ class MainActivity : AppCompatActivity() {
addAction(JS8EngineService.ACTION_ERROR)
addAction(JS8EngineService.ACTION_RADIO_FREQUENCY)
addAction(JS8EngineService.ACTION_TIME_DRIFT)
addAction(JS8EngineService.ACTION_RIG_STATUS)
}
LocalBroadcastManager.getInstance(this)
.registerReceiver(monitorReceiver, monitorFilter)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,41 @@ class JS8EngineService : Service() {
putExtra(EXTRA_STATE, state)
}
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)

// The connected flags are assigned in too many places to broadcast from
// each one, so poll while the engine runs. Every poll sends, not only
// changes: local broadcasts are not sticky, and a UI that attaches
// late or missed one while stopped has no other way to catch up
if (state == STATE_RUNNING) {
startRigStatusPolling()
} else {
stopRigStatusPolling()
}
}

private val rigStatusHandler = Handler(Looper.getMainLooper())
private val rigStatusRunnable = object : Runnable {
override fun run() {
broadcastRigStatus(isRigControlConnected())
rigStatusHandler.postDelayed(this, RIG_STATUS_POLL_INTERVAL_MS)
}
}

private fun startRigStatusPolling() {
rigStatusHandler.removeCallbacks(rigStatusRunnable)
rigStatusHandler.post(rigStatusRunnable)
}

private fun stopRigStatusPolling() {
rigStatusHandler.removeCallbacks(rigStatusRunnable)
broadcastRigStatus(false)
}

private fun broadcastRigStatus(connected: Boolean) {
val intent = Intent(ACTION_RIG_STATUS).apply {
putExtra(EXTRA_RIG_CONNECTED, connected)
}
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
}

private fun broadcastDecode(
Expand Down Expand Up @@ -3902,6 +3937,12 @@ class JS8EngineService : Service() {
private fun setFrequency(frequencyHz: Long) {
if (!isRigControlConnected()) {
Log.d(TAG, "Cannot set frequency: rig control not connected")
// A configured rig with no link is a failure to report; with rig
// control off the pick only stores the dial value, so stay quiet
if (!rigCtlErrorShown && rigControlMode != "none" && rigControlMode != "rts_ptt") {
broadcastError("Rig control is not connected")
rigCtlErrorShown = true
}
return
}

Expand Down Expand Up @@ -3953,6 +3994,7 @@ class JS8EngineService : Service() {
private const val PREF_MY_INFO = "my_info"
private const val PREF_MY_STATUS = "my_status"
private const val PREF_PSK_REPORTER = "psk_reporter"
private const val RIG_STATUS_POLL_INTERVAL_MS = 2000L
private const val PREF_TRUSDX_DIAGNOSTICS_ENABLED = "trusdx_diagnostics_enabled"
private const val HEARD_LIMIT = 4
private const val HEARD_WINDOW_MS = 15 * 60 * 1000L
Expand Down Expand Up @@ -3997,6 +4039,7 @@ class JS8EngineService : Service() {
const val ACTION_TIME_SYNC_ONCE = "com.js8call.example.ACTION_TIME_SYNC_ONCE"
const val ACTION_SET_TIME_DRIFT = "com.js8call.example.ACTION_SET_TIME_DRIFT"
const val ACTION_TIME_DRIFT = "com.js8call.example.ACTION_TIME_DRIFT"
const val ACTION_RIG_STATUS = "com.js8call.example.ACTION_RIG_STATUS"

// Engine states
const val STATE_STOPPED = "stopped"
Expand All @@ -4006,6 +4049,7 @@ class JS8EngineService : Service() {

// Extras
const val EXTRA_STATE = "state"
const val EXTRA_RIG_CONNECTED = "rig_connected"
const val EXTRA_UTC = "utc"
const val EXTRA_SNR = "snr"
const val EXTRA_DT = "dt"
Expand Down
82 changes: 64 additions & 18 deletions android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class MonitorFragment : Fragment() {
private lateinit var waterfallView: WaterfallView
private lateinit var stateDot: ImageView
private lateinit var statusText: TextView
private lateinit var rigIndicator: ImageView
private lateinit var frequencyButton: MaterialButton
private lateinit var powerSwitch: MaterialSwitch
private lateinit var telemetryText: TextView
Expand All @@ -47,6 +48,8 @@ class MonitorFragment : Fragment() {

private var lastLabelRes = 0
private var lastColorRes = 0
private var lastRigColorRes = -1
private var lastRigDescRes = -1

// Set true while the switch is moved in code, so the listener can tell a
// state update apart from a tap.
Expand All @@ -71,10 +74,19 @@ class MonitorFragment : Fragment() {
waterfallView = view.findViewById(R.id.waterfall_view)
stateDot = view.findViewById(R.id.state_dot)
statusText = view.findViewById(R.id.status_text)
rigIndicator = view.findViewById(R.id.rig_indicator)
frequencyButton = view.findViewById(R.id.frequency_button)
powerSwitch = view.findViewById(R.id.power_switch)
telemetryText = view.findViewById(R.id.telemetry_text)

// Navigation keeps this fragment instance but recreates its views at
// their layout defaults, so the repaint memo has to reset with them or
// the first paint skips and the fresh views stay stuck on the defaults
lastLabelRes = 0
lastColorRes = 0
lastRigColorRes = -1
lastRigDescRes = -1

// Set up waterfall offset callback
waterfallView.bindRenderer(viewModel.getWaterfallRenderer())
waterfallView.onOffsetChanged = { offsetHz ->
Expand Down Expand Up @@ -103,6 +115,11 @@ class MonitorFragment : Fragment() {
.setOnClickListener { showOverflowMenu(it) }
}

override fun onResume() {
super.onResume()
updateRigIndicator()
}

private fun observeViewModel() {
// Observe status
viewModel.status.observe(viewLifecycleOwner) { status ->
Expand All @@ -119,6 +136,8 @@ class MonitorFragment : Fragment() {

transmitViewModel.txState.observe(viewLifecycleOwner) { renderState() }

viewModel.rigConnected.observe(viewLifecycleOwner) { updateRigIndicator() }

viewModel.radioFrequency.observe(viewLifecycleOwner) { frequencyHz ->
if (frequencyHz != null && frequencyHz > 0) {
updateFrequencyFromRadio(frequencyHz)
Expand All @@ -138,6 +157,8 @@ class MonitorFragment : Fragment() {
powerSwitch.isChecked = shouldBeOn
applyingSwitchState = false

updateRigIndicator()

val (labelRes, colorRes) = when {
transmitting -> R.string.monitor_state_transmitting to R.color.tx_button_transmitting
engineState == EngineState.RUNNING -> R.string.monitor_state_receiving to R.color.snr_excellent
Expand All @@ -151,10 +172,47 @@ class MonitorFragment : Fragment() {
lastColorRes = colorRes

statusText.setText(labelRes)
// Transmitting and Error are both red, so an error changes the mark
// itself rather than relying on a shade the eye has to measure.
stateDot.setImageResource(
if (engineState == EngineState.ERROR) R.drawable.ic_error_outline
else R.drawable.status_dot
)
stateDot.imageTintList =
ColorStateList.valueOf(ContextCompat.getColor(requireContext(), colorRes))
}

/** Shown only when rig control is switched on in Settings. */
private fun updateRigIndicator() {
val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext())
val rigEnabled = prefs.getBoolean("rig_control_enabled", false) &&
prefs.getString("rig_type", "none") != "none"
val engineState = viewModel.status.value?.state ?: EngineState.STOPPED
val connected = viewModel.rigConnected.value == true
val (colorRes, descRes) = when {
!rigEnabled -> 0 to 0
connected -> R.color.snr_excellent to R.string.monitor_rig_connected
engineState == EngineState.STARTING -> R.color.tx_button_queued to R.string.monitor_rig_connecting
// An error counts as an attempt: a failed start is usually the rig failing to connect
engineState == EngineState.RUNNING || engineState == EngineState.ERROR ->
R.color.message_failed to R.string.monitor_rig_disconnected
else -> R.color.message_pending to R.string.monitor_rig_disconnected
}
// Reached at the spectrum rate through renderState; skip unchanged paints
if (colorRes == lastRigColorRes && descRes == lastRigDescRes) return
lastRigColorRes = colorRes
lastRigDescRes = descRes

if (colorRes == 0) {
rigIndicator.visibility = View.GONE
return
}
rigIndicator.visibility = View.VISIBLE
rigIndicator.imageTintList =
ColorStateList.valueOf(ContextCompat.getColor(requireContext(), colorRes))
rigIndicator.contentDescription = getString(descRes)
}

private fun renderTelemetry(status: MonitorStatus) {
val drift = if (status.timeDriftMs != 0L) {
String.format("%+d ms", status.timeDriftMs)
Expand Down Expand Up @@ -401,25 +459,13 @@ class MonitorFragment : Fragment() {
val frequencyHz = frequencyValues[position].toLongOrNull() ?: return
android.util.Log.d("MonitorFragment", "Frequency selected: ${frequencyEntries[position]} ($frequencyHz Hz)")

// Check if rig control is enabled
val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext())
val rigControlEnabled = prefs.getBoolean("rig_control_enabled", false)
val rigType = prefs.getString("rig_type", "none")

if (rigControlEnabled && (rigType == "network" || rigType == "hamlib_usb" || rigType == "trusdx_serial" || rigType == "qmx_serial")) {
// Send frequency change to service
val intent = Intent(requireContext(), JS8EngineService::class.java).apply {
action = JS8EngineService.ACTION_SET_FREQUENCY
putExtra(JS8EngineService.EXTRA_FREQUENCY_HZ, frequencyHz)
}
requireContext().startService(intent)

Snackbar.make(requireView(), "Setting frequency to ${frequencyEntries[position]}", Snackbar.LENGTH_SHORT).show()
} else if (rigControlEnabled && rigType == "rts_ptt") {
android.util.Log.d("MonitorFragment", "RTS PTT mode does not support frequency control")
} else {
android.util.Log.d("MonitorFragment", "Rig control not enabled or not supported type, skipping frequency change")
// The service knows which rigs take frequency control; it drops a
// request that has no rig link and reports failures itself
val intent = Intent(requireContext(), JS8EngineService::class.java).apply {
action = JS8EngineService.ACTION_SET_FREQUENCY
putExtra(JS8EngineService.EXTRA_FREQUENCY_HZ, frequencyHz)
}
requireContext().startService(intent)
}

private fun updateFrequencyFromRadio(frequencyHz: Long) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ class MonitorViewModel(application: Application) : AndroidViewModel(application)
private val _radioFrequency = MutableLiveData<Long>()
val radioFrequency: LiveData<Long> = _radioFrequency

private val _rigConnected = MutableLiveData<Boolean>(false)
val rigConnected: LiveData<Boolean> = _rigConnected

private val waterfallRenderer = WaterfallRenderer()

init {
Expand All @@ -50,6 +53,8 @@ class MonitorViewModel(application: Application) : AndroidViewModel(application)
_status.value = _status.value?.copy(state = EngineState.STOPPED)
_isRunning.value = false
waterfallRenderer.clear()
// The rig link cannot outlive the engine that opened it
updateRigConnected(false)
// Service will be stopped by fragment
}

Expand All @@ -67,6 +72,13 @@ class MonitorViewModel(application: Application) : AndroidViewModel(application)
}
}

/**
* Update whether rig control has a live link to the radio.
*/
fun updateRigConnected(connected: Boolean) {
_rigConnected.value = connected
}

/**
* Update spectrum data from engine.
*/
Expand Down
10 changes: 10 additions & 0 deletions android/app/src/main/res/drawable/ic_error_outline.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M11,15h2v2h-2v-2zM11,7h2v6h-2V7zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
</vector>
13 changes: 13 additions & 0 deletions android/app/src/main/res/drawable/ic_sync_alt.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<!-- No android:tint: MonitorFragment tints this to show the link state -->
<group android:translateY="960">
<path
android:fillColor="@android:color/white"
android:pathData="M280,-120 L80,-320l200,-200 57,56 -104,104h607v80H233l104,104 -57,56ZM680,-440l-57,-56 104,-104L120,-600v-80h607L623,-784l57,-56 200,200 -200,200Z" />
</group>
</vector>
11 changes: 11 additions & 0 deletions android/app/src/main/res/layout/monitor_status_card.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@
android:textAppearance="?attr/textAppearanceTitleMedium"
tools:text="Receiving" />

<!-- Only shown when rig control is switched on in Settings -->
<ImageView
android:id="@+id/rig_indicator"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/monitor_rig_disconnected"
android:src="@drawable/ic_sync_alt"
android:visibility="gone"
tools:visibility="visible" />

<Space
android:layout_width="0dp"
android:layout_height="1dp"
Expand Down
3 changes: 3 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
<string name="monitor_menu_time_drift_reset">Reset time drift</string>
<string name="monitor_audio_device_switching">Switching to %s…</string>
<string name="monitor_audio_device_selected">Audio device: %s</string>
<string name="monitor_rig_connected">Rig connected</string>
<string name="monitor_rig_connecting">Rig connecting</string>
<string name="monitor_rig_disconnected">Rig not connected</string>

<string name="settings_category_about">About</string>
<string name="settings_version">Version</string>
Expand Down
Loading