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..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 @@ -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( @@ -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 } @@ -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 @@ -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" @@ -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" 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..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 @@ -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,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 -> @@ -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 -> @@ -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) @@ -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 @@ -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) @@ -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) { 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