From 4b1a80a29f4bde3b15454762029bf2373e766352 Mon Sep 17 00:00:00 2001 From: tekstrand Date: Thu, 3 Sep 2026 16:10:00 -0500 Subject: [PATCH 1/4] Added the rig link indicator and the error mark to the Monitor strip. A rig icon appears beside the state word when rig control is switched on in Settings. It is grey before the engine tries, amber while starting, green when CAT is alive and red when a running or failed engine has no link, which are the colors the state dot already uses for the same ideas. The service reports the link through a new rig status broadcast: the connection flags are assigned in too many places to report from each one, so it polls every two seconds while the engine runs and broadcasts only on a change. The flag records the last connect result, so a rig unplugged mid session still reads as connected until something tries to use it, and stopping reports the link down, since it cannot outlive the engine that opened it. The error state draws a warning glyph in place of the dot. Transmitting and Error are both red, in shades close enough to be hard to tell apart at a glance, and red stays on Transmitting since red for on air is the convention. Shape rather than another shade also keeps the strip readable for the red and green color vision deficiencies, which matters here because receiving and transmitting already lean on those two colors. --- .../java/com/js8call/example/MainActivity.kt | 5 ++ .../example/service/JS8EngineService.kt | 39 +++++++++++++++ .../com/js8call/example/ui/MonitorFragment.kt | 50 +++++++++++++++++++ .../js8call/example/ui/MonitorViewModel.kt | 12 +++++ .../main/res/drawable/ic_error_outline.xml | 10 ++++ .../app/src/main/res/drawable/ic_sync_alt.xml | 13 +++++ .../main/res/layout/monitor_status_card.xml | 11 ++++ android/app/src/main/res/values/strings.xml | 3 ++ 8 files changed, 143 insertions(+) create mode 100644 android/app/src/main/res/drawable/ic_error_outline.xml create mode 100644 android/app/src/main/res/drawable/ic_sync_alt.xml diff --git a/android/app/src/main/java/com/js8call/example/MainActivity.kt b/android/app/src/main/java/com/js8call/example/MainActivity.kt index 159db6a9..4ddb8708 100644 --- a/android/app/src/main/java/com/js8call/example/MainActivity.kt +++ b/android/app/src/main/java/com/js8call/example/MainActivity.kt @@ -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) + } } } } @@ -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) diff --git a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt index 620afd93..b72333e9 100644 --- a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt +++ b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt @@ -1630,6 +1630,42 @@ 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 and report only on a change + if (state == STATE_RUNNING) { + startRigStatusPolling() + } else { + stopRigStatusPolling() + } + } + + private val rigStatusHandler = Handler(Looper.getMainLooper()) + private var lastRigConnected = false + 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) { + if (lastRigConnected == connected) return + lastRigConnected = connected + val intent = Intent(ACTION_RIG_STATUS).apply { + putExtra(EXTRA_RIG_CONNECTED, connected) + } + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) } private fun broadcastDecode( @@ -3953,6 +3989,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 @@ -3997,6 +4034,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" @@ -4006,6 +4044,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" diff --git a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt index e4cdd94a..43369b0c 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt @@ -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 @@ -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. @@ -71,6 +74,7 @@ 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) @@ -103,6 +107,11 @@ class MonitorFragment : Fragment() { .setOnClickListener { showOverflowMenu(it) } } + override fun onResume() { + super.onResume() + updateRigIndicator() + } + private fun observeViewModel() { // Observe status viewModel.status.observe(viewLifecycleOwner) { status -> @@ -119,6 +128,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) @@ -138,6 +149,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 @@ -151,10 +164,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) diff --git a/android/app/src/main/java/com/js8call/example/ui/MonitorViewModel.kt b/android/app/src/main/java/com/js8call/example/ui/MonitorViewModel.kt index f8b124ce..d5d627dc 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MonitorViewModel.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MonitorViewModel.kt @@ -29,6 +29,9 @@ class MonitorViewModel(application: Application) : AndroidViewModel(application) private val _radioFrequency = MutableLiveData() val radioFrequency: LiveData = _radioFrequency + private val _rigConnected = MutableLiveData(false) + val rigConnected: LiveData = _rigConnected + private val waterfallRenderer = WaterfallRenderer() init { @@ -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 } @@ -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. */ diff --git a/android/app/src/main/res/drawable/ic_error_outline.xml b/android/app/src/main/res/drawable/ic_error_outline.xml new file mode 100644 index 00000000..e87d5574 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_error_outline.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_sync_alt.xml b/android/app/src/main/res/drawable/ic_sync_alt.xml new file mode 100644 index 00000000..1b62330f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_sync_alt.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/monitor_status_card.xml b/android/app/src/main/res/layout/monitor_status_card.xml index a6e85d56..8536a0bd 100644 --- a/android/app/src/main/res/layout/monitor_status_card.xml +++ b/android/app/src/main/res/layout/monitor_status_card.xml @@ -35,6 +35,17 @@ android:textAppearance="?attr/textAppearanceTitleMedium" tools:text="Receiving" /> + + + Reset time drift Switching to %s… Audio device: %s + Rig connected + Rig connecting + Rig not connected About Version From 23ada96a6970ad3247ef50cd7d9bf4dc7ac11174 Mon Sep 17 00:00:00 2001 From: tekstrand Date: Sat, 5 Sep 2026 16:42:58 -0500 Subject: [PATCH 2/4] Sent every frequency pick to the service instead of guessing which rig types accept it. The fragment carried its own list of frequency capable rig types next to a snackbar that claimed the frequency was being set, but the list was a copy of the dispatch table the service already owns, and the claim was wrong whenever the rig was configured but never connected. The service drops a request without a rig link and reports a real failure through the error broadcast, so the pick is now quiet when it works and loud when it fails, and adding a rig type no longer means remembering a fragment. The pick also reaches the service with rig control off, which only sets the dial value the service would read back from the last_frequency preference anyway. --- .../example/service/JS8EngineService.kt | 6 +++++ .../com/js8call/example/ui/MonitorFragment.kt | 24 +++++-------------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt index b72333e9..ffaa28e4 100644 --- a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt +++ b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt @@ -3938,6 +3938,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 } diff --git a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt index 43369b0c..5fbbaee9 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt @@ -451,25 +451,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) { From eec4eb942308648ae44b8675d1fea2e9c2ba3568 Mon Sep 17 00:00:00 2001 From: tekstrand Date: Tue, 8 Sep 2026 17:51:31 -0500 Subject: [PATCH 3/4] Reset the status strip repaint memo when the Monitor view is recreated. The nav rail moves between tabs with the Navigation Component, which destroys the Monitor fragment's view on the way out and inflates a fresh one on the way back while keeping the fragment instance. The fresh views come up at their layout defaults, Off with a grey dot and the rig icon gone, but lastLabelRes, lastColorRes, lastRigColorRes and lastRigDescRes are fields on the instance and still hold the values from before the trip. With the engine running the first paint after the return computed Receiving, matched the stale memo, and skipped, so the strip sat on Off with the switch on and the waterfall scrolling underneath. Sending a heartbeat is the easiest way to hit it, since that is a trip to the Transmit tab and back. Clearing the four fields in onViewCreated makes the first paint of every new view unconditional. Verified on the Fire HD 10 with the rig attached: engine on, over to Transmit, back to Monitor, and the strip still reads Receiving with the green rig icon. --- .../main/java/com/js8call/example/ui/MonitorFragment.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt index 5fbbaee9..ceadbfc0 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt @@ -79,6 +79,14 @@ class MonitorFragment : Fragment() { 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 -> From 94675282197b7187217d538afdc28d0dbe440383 Mon Sep 17 00:00:00 2001 From: tekstrand Date: Fri, 11 Sep 2026 16:19:36 -0500 Subject: [PATCH 4/4] Broadcast the rig status on every poll instead of only on a change. The poller gated its broadcast behind a last-sent flag, which reads as tidy but breaks the moment the UI is not the same UI that heard the first one. Local broadcasts are not sticky, so a MonitorViewModel created after the rig connected starts at disconnected and never hears otherwise until the rig actually changes state, and the same thing happens to a ViewModel that missed a change while the activity was stopped with its receivers unregistered. Sending the current state every two seconds while the engine runs closes both, and it costs nothing on the UI side because the fragment already skips a paint whose color and description have not moved. The gate and its field are gone. Found in review on PR 92. --- .../java/com/js8call/example/service/JS8EngineService.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt index ffaa28e4..c52d8ad0 100644 --- a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt +++ b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt @@ -1632,7 +1632,9 @@ class JS8EngineService : Service() { 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 and report only on a change + // 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 { @@ -1641,7 +1643,6 @@ class JS8EngineService : Service() { } private val rigStatusHandler = Handler(Looper.getMainLooper()) - private var lastRigConnected = false private val rigStatusRunnable = object : Runnable { override fun run() { broadcastRigStatus(isRigControlConnected()) @@ -1660,8 +1661,6 @@ class JS8EngineService : Service() { } private fun broadcastRigStatus(connected: Boolean) { - if (lastRigConnected == connected) return - lastRigConnected = connected val intent = Intent(ACTION_RIG_STATUS).apply { putExtra(EXTRA_RIG_CONNECTED, connected) }