From 214aced071d2da0f103cb2f2b5f2d3a0a9348478 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Thu, 15 Oct 2026 14:05:25 +0200 Subject: [PATCH 1/3] feat(dtc): clear one module at a time, warn when the engine is running An expanded module with codes gets a "Clear codes in " row that clears that module alone after one confirmation. A read or clear of some modules now refreshes only those: the other modules keep their codes instead of vanishing from the list. Before any clear, a recent engine speed or vehicle status reading showing a running engine adds a warning (phone and AA), since most modules refuse the clear then. --- CLAUDE.md | 9 ++ ...sticTroubleCodePreferenceDialogFragment.kt | 76 +++++++++++++--- .../dtc/DiagnosticTroubleCodeViewAdapter.kt | 20 ++++- .../main/res/drawable/dtc_clear_module.xml | 11 +++ .../main/res/layout/item_dtc_module_clear.xml | 25 ++++++ app/src/main/res/values-pl/strings.xml | 7 ++ app/src/main/res/values/strings.xml | 7 ++ .../nav/DiagnosticTroubleCodesScreen.kt | 13 ++- automotive/src/main/res/values-pl/strings.xml | 1 + automotive/src/main/res/values/strings.xml | 1 + .../datalogger/VehicleCapabilitiesManager.kt | 20 ++++- .../bl/datalogger/WorkflowOrchestrator.kt | 3 +- .../graphs/bl/datalogger/dtc/DtcListItem.kt | 33 ++++++- .../graphs/bl/datalogger/dtc/DtcStoredList.kt | 53 ++++++++++++ .../graphs/bl/datalogger/dtc/EngineState.kt | 66 ++++++++++++++ .../VehicleCapabilitiesManagerTest.kt | 36 ++++++++ .../bl/datalogger/dtc/DtcListItemTest.kt | 44 ++++++++++ .../bl/datalogger/dtc/DtcStoredListTest.kt | 84 ++++++++++++++++++ .../bl/datalogger/dtc/EngineStateTest.kt | 68 +++++++++++++++ doc/specs/dtc-clear-module.md | 86 +++++++++++++++++++ doc/specs/dtc-roadmap.md | 4 + 21 files changed, 643 insertions(+), 24 deletions(-) create mode 100644 app/src/main/res/drawable/dtc_clear_module.xml create mode 100644 app/src/main/res/layout/item_dtc_module_clear.xml create mode 100644 datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/DtcStoredList.kt create mode 100644 datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/EngineState.kt create mode 100644 datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/DtcStoredListTest.kt create mode 100644 datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/EngineStateTest.kt create mode 100644 doc/specs/dtc-clear-module.md diff --git a/CLAUDE.md b/CLAUDE.md index 87be30e2..64c7b2fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -377,6 +377,15 @@ gives the timestamped jar name; a CI run started before that timestamp cannot se with no modules (list collapsed to "ECU"). Requests now wait in the queue; `onDTCCompleted` uses the finished request's kind / modules and holds back `DATA_LOGGER_DTC_ACTION_COMPLETED` while another one waits. Logcat: "DTC action completed: , ... next=". Any new DTC entry point must go through `submit()`. +* **A read / clear of some modules updates only those** (`dtc/DtcStoredList.kt`, spec `doc/specs/dtc-clear-module.md`). + The clear's follow-up read covers the cleared modules alone, so `updateDTC` replacing the list made the other modules' + codes vanish. `mergedOver` keeps the untargeted modules' codes / statuses for READ / CLEAR of the same known vehicle; + CONNECT replaces (a module removed from the table must not linger). History records the read alone. The phone's + "Clear codes in " row (`DtcListItem.ModuleClear`) needs `dtcModuleRequestKey`: codes carry the display + label, `scheduleDTCCleanup` takes table `requestKey`s. +* Engine-running check before a clear: `EngineStateTracker` (registered on `MetricsObserver`) keeps the last RPM / + vehicle-status reading; `engineRunning()` is null when older than 10 s or never seen. It only warns — the running + query may have no engine PID, so null must never block. * **New-code notification** (`dtc/DtcNewCodes.kt`, spec `doc/specs/dtc-new-code-notification.md`): `updateDTC` ends with `DtcNewCodesAnnouncer.announce`, so every connect path is covered; only `CONNECT` scans with a VIN reply; all codes on the vehicle's first scan, else only `diff.isNew` ones. "First scan" is read before `DtcHistoryStore.append`: diff --git a/app/src/main/java/org/obd/graphs/preferences/dtc/DiagnosticTroubleCodePreferenceDialogFragment.kt b/app/src/main/java/org/obd/graphs/preferences/dtc/DiagnosticTroubleCodePreferenceDialogFragment.kt index 254b165e..0492ae2b 100644 --- a/app/src/main/java/org/obd/graphs/preferences/dtc/DiagnosticTroubleCodePreferenceDialogFragment.kt +++ b/app/src/main/java/org/obd/graphs/preferences/dtc/DiagnosticTroubleCodePreferenceDialogFragment.kt @@ -16,6 +16,7 @@ */ package org.obd.graphs.preferences.dtc +import android.app.AlertDialog import android.content.Intent import android.os.Bundle import android.text.format.DateUtils @@ -45,10 +46,12 @@ import org.obd.graphs.bl.datalogger.dataLoggerSettings import org.obd.graphs.bl.datalogger.dtc.DtcClearOutcome import org.obd.graphs.bl.datalogger.dtc.DtcHistoryStore import org.obd.graphs.bl.datalogger.dtc.DtcReadInfoLabels +import org.obd.graphs.bl.datalogger.dtc.EngineStateTracker import org.obd.graphs.bl.datalogger.dtc.PREF_DTC_DESELECTED_MODULES import org.obd.graphs.bl.datalogger.dtc.collapsedBy import org.obd.graphs.bl.datalogger.dtc.displayCode import org.obd.graphs.bl.datalogger.dtc.dtcActionTimeoutMs +import org.obd.graphs.bl.datalogger.dtc.dtcModuleRequestKey import org.obd.graphs.bl.datalogger.dtc.dtcStatus import org.obd.graphs.bl.datalogger.dtc.markedWith import org.obd.graphs.bl.datalogger.dtc.sortedForDisplay @@ -58,6 +61,7 @@ import org.obd.graphs.bl.datalogger.dtc.toDidLines import org.obd.graphs.bl.datalogger.dtc.toDtcListItems import org.obd.graphs.bl.datalogger.dtc.unansweredModules import org.obd.graphs.bl.datalogger.dtc.withGoneSection +import org.obd.graphs.bl.datalogger.dtc.withModuleClearActions import org.obd.graphs.bl.datalogger.dtc.withModuleSummaries import org.obd.graphs.bl.readiness.PREF_READINESS_LAST import org.obd.graphs.bl.readiness.ReadinessSnapshot @@ -136,7 +140,8 @@ internal class DiagnosticTroubleCodePreferenceDialogFragment : CoreDialogFragmen onModuleHeaderClick = { header -> moduleExpanded[header.module] = !header.expanded refreshList() - } + }, + onModuleClearClick = { module -> clearModule(module) } ) recyclerView.layoutManager = GridLayoutManager(context, 1) recyclerView.adapter = adapter @@ -190,19 +195,13 @@ internal class DiagnosticTroubleCodePreferenceDialogFragment : CoreDialogFragmen clearButton.setOnClickListener { if (DataLoggerRepository.isRunning()) { - pickDtcModules( - titleRes = R.string.pref_dtc_clean_dialog_title, - confirmRes = R.string.pref_dtc_select_modules_confirm_clear, - messageRes = R.string.pref_dtc_clean_dialog_confirm_message, - confirmWhenEmpty = true - ) { selectedModules -> - setLoadingState(true, selectedModules.size) - withDataLogger { - scheduleDTCCleanup(selectedModules) - } - - toast(R.string.pref_dtc_clean_dialog_send_message) - clearButton.setText(R.string.dtc_action_clearing) + confirmEngineOff { + pickDtcModules( + titleRes = R.string.pref_dtc_clean_dialog_title, + confirmRes = R.string.pref_dtc_select_modules_confirm_clear, + messageRes = R.string.pref_dtc_clean_dialog_confirm_message, + confirmWhenEmpty = true + ) { selectedModules -> startClear(selectedModules) } } } else { toast(R.string.pref_dtc_no_connection_established) @@ -210,6 +209,53 @@ internal class DiagnosticTroubleCodePreferenceDialogFragment : CoreDialogFragmen } } + private fun startClear(modules: Set) { + setLoadingState(true, modules.size) + withDataLogger { + scheduleDTCCleanup(modules) + } + + toast(R.string.pref_dtc_clean_dialog_send_message) + clearButton.setText(R.string.dtc_action_clearing) + } + + // A recent reading says the engine runs: most modules would refuse the clear (7F 14 22), so say so first and let + // the user go on anyway. Unknown (no engine PID in the running query) goes straight on. + private fun confirmEngineOff(onConfirmed: () -> Unit) { + if (EngineStateTracker.engineRunning() != true) { + onConfirmed() + return + } + AlertDialog + .Builder(requireContext()) + .setTitle(R.string.dtc_engine_running_title) + .setMessage(R.string.dtc_engine_running_message) + .setPositiveButton(R.string.dtc_engine_running_clear_anyway) { _, _ -> onConfirmed() } + .setNegativeButton(R.string.pref_dtc_select_modules_cancel, null) + .show() + } + + // "Clear codes in ": one module, one confirmation (with the engine warning folded in), no picker. + private fun clearModule(module: String) { + if (!DataLoggerRepository.isRunning()) { + toast(R.string.pref_dtc_no_connection_established) + return + } + val requestKey = dtcModuleRequestKey(DiagnosticRequestIDManager.getMappings(), module) ?: return + val message = + listOfNotNull( + getString(R.string.dtc_engine_running_message).takeIf { EngineStateTracker.engineRunning() == true }, + getString(R.string.dtc_module_clear_message) + ).joinToString("\n\n") + AlertDialog + .Builder(requireContext()) + .setTitle(getString(R.string.dtc_module_clear_title, module)) + .setMessage(message) + .setPositiveButton(R.string.dtc_module_clear_confirm) { _, _ -> startClear(setOf(requestKey)) } + .setNegativeButton(R.string.pref_dtc_select_modules_cancel, null) + .show() + } + // Lets the user restrict a DTC read/clear to a subset of the configured Diagnostic Request ID // modules for this action only (not persisted), folding in an optional message (a plain // description for the Scan flow, a destructive-action warning for the Clear flow) so a single @@ -469,6 +515,7 @@ internal class DiagnosticTroubleCodePreferenceDialogFragment : CoreDialogFragmen private fun refreshList() { val codes = diagnosticTroubleCodes() + val mappings = DiagnosticRequestIDManager.getMappings() // New / gone markers from the vehicle's DTC history, for the list stored with this read info. val history = DtcHistoryStore.diff(VehicleCapabilitiesManager.getDtcReadInfo()) adapter.submitList( @@ -484,6 +531,7 @@ internal class DiagnosticTroubleCodePreferenceDialogFragment : CoreDialogFragmen ).markedWith(history) .withModuleSummaries() .collapsedBy { moduleExpanded[it] } + .withModuleClearActions { dtcModuleRequestKey(mappings, it) != null } .withGoneSection(history, goneExpanded) ) diff --git a/app/src/main/java/org/obd/graphs/preferences/dtc/DiagnosticTroubleCodeViewAdapter.kt b/app/src/main/java/org/obd/graphs/preferences/dtc/DiagnosticTroubleCodeViewAdapter.kt index 3f1baf7b..b4d786e3 100644 --- a/app/src/main/java/org/obd/graphs/preferences/dtc/DiagnosticTroubleCodeViewAdapter.kt +++ b/app/src/main/java/org/obd/graphs/preferences/dtc/DiagnosticTroubleCodeViewAdapter.kt @@ -62,6 +62,7 @@ private const val VIEW_TYPE_HEADER = 0 private const val VIEW_TYPE_DTC = 1 private const val VIEW_TYPE_MESSAGE = 2 private const val VIEW_TYPE_GONE_HEADER = 3 +private const val VIEW_TYPE_MODULE_CLEAR = 4 private const val GONE_ROW_ALPHA = 0.6f private const val BADGE_FILL_ALPHA = 0x33 @@ -168,10 +169,12 @@ internal fun TextView.showBadge( } // onGoneHeaderClick / onModuleHeaderClick: a section header was tapped; the owner rebuilds the list expanded / collapsed. +// onModuleClearClick: "Clear codes in " was tapped; the owner confirms and clears. internal class DiagnosticTroubleCodeViewAdapter internal constructor( context: Context?, private val onGoneHeaderClick: () -> Unit = {}, - private val onModuleHeaderClick: (DtcListItem.ModuleHeader) -> Unit = {} + private val onModuleHeaderClick: (DtcListItem.ModuleHeader) -> Unit = {}, + private val onModuleClearClick: (String) -> Unit = {} ) : ListAdapter(DiffCallback) { private val mInflater: LayoutInflater = LayoutInflater.from(context) @@ -185,6 +188,7 @@ internal class DiagnosticTroubleCodeViewAdapter internal constructor( is DtcListItem.DtcRow -> VIEW_TYPE_DTC is DtcListItem.Message -> VIEW_TYPE_MESSAGE is DtcListItem.GoneHeader -> VIEW_TYPE_GONE_HEADER + is DtcListItem.ModuleClear -> VIEW_TYPE_MODULE_CLEAR } override fun onCreateViewHolder( @@ -195,6 +199,7 @@ internal class DiagnosticTroubleCodeViewAdapter internal constructor( VIEW_TYPE_HEADER -> HeaderViewHolder(mInflater.inflate(R.layout.item_dtc_module_header, parent, false)) VIEW_TYPE_MESSAGE -> MessageViewHolder(mInflater.inflate(R.layout.item_dtc_message, parent, false)) VIEW_TYPE_GONE_HEADER -> GoneHeaderViewHolder(mInflater.inflate(R.layout.item_dtc_module_header, parent, false)) + VIEW_TYPE_MODULE_CLEAR -> ModuleClearViewHolder(mInflater.inflate(R.layout.item_dtc_module_clear, parent, false)) else -> DtcViewHolder(mInflater.inflate(R.layout.item_dtc, parent, false)) } @@ -207,6 +212,7 @@ internal class DiagnosticTroubleCodeViewAdapter internal constructor( is DtcListItem.DtcRow -> bindDtc(holder as DtcViewHolder, item) is DtcListItem.Message -> (holder as MessageViewHolder).bind(item) is DtcListItem.GoneHeader -> (holder as GoneHeaderViewHolder).bind(item, onGoneHeaderClick) + is DtcListItem.ModuleClear -> (holder as ModuleClearViewHolder).bind(item, onModuleClearClick) } } @@ -468,6 +474,18 @@ internal class DiagnosticTroubleCodeViewAdapter internal constructor( } } + internal class ModuleClearViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val action: TextView = itemView.findViewById(R.id.dtc_module_clear) + + fun bind( + item: DtcListItem.ModuleClear, + onClick: (String) -> Unit + ) { + action.text = itemView.context.getString(R.string.dtc_module_clear, item.module) + action.setOnClickListener { onClick(item.module) } + } + } + internal class MessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val text: TextView = itemView.findViewById(R.id.dtc_message) diff --git a/app/src/main/res/drawable/dtc_clear_module.xml b/app/src/main/res/drawable/dtc_clear_module.xml new file mode 100644 index 00000000..7c6c5a21 --- /dev/null +++ b/app/src/main/res/drawable/dtc_clear_module.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/layout/item_dtc_module_clear.xml b/app/src/main/res/layout/item_dtc_module_clear.xml new file mode 100644 index 00000000..65c860ac --- /dev/null +++ b/app/src/main/res/layout/item_dtc_module_clear.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index c13bfc58..506b7af9 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -787,6 +787,13 @@ Brak odpowiedzi Brak odpowiedzi: moduł nie odpowiedział na odczyt. Jego kody są nieznane, więc nie wpływa na oznaczenia Nowy i Już nie zgłaszane. Odmowa + Skasuj kody modułu %1$s + Skasować kody modułu %1$s? + Z pojazdu zostaną skasowane tylko kody tego modułu i ich dane zamrożonej klatki; pozostałe moduły zachowają swoje. Aplikacja zachowuje kopię w historii kodów.\n\nUpewnij się, że silnik jest WYŁĄCZONY, ale stacyjka jest WŁĄCZONA.\n\nJeśli to moduł silnika, monitory gotowości również zostaną wyzerowane. + Skasuj + Silnik pracuje + Wygląda na to, że silnik pracuje. Większość modułów odmawia skasowania kodów przy pracującym silniku. Wyłącz silnik, zostaw włączoną stacyjkę i spróbuj ponownie. + Skasuj mimo to Odmowa: moduł odpowiedział, ale nie przesłał kodów. Moduły bez odpowiedzi: %1$s Już nie zgłaszane diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d589900f..cdd7c0ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1012,6 +1012,13 @@ No response No response: the module did not answer the scan. Its codes are unknown, so it is left out of New and No longer reported. Refused + Clear codes in %1$s + Clear codes in %1$s? + Only this module\'s trouble codes and their freeze frame data are erased from the vehicle; the other modules keep theirs. The app keeps a copy in its code history.\n\nMake sure the engine is OFF, but the ignition is ON.\n\nIf this is the engine module, the readiness monitors reset too. + Clear + The engine is running + The engine seems to be running. Most modules refuse to clear codes while it runs. Turn the engine off, leave the ignition on and try again. + Clear anyway Refused: the module answered but declined to send its codes. Modules without an answer: %1$s No longer reported diff --git a/automotive/src/main/java/org/obd/graphs/aa/screen/nav/DiagnosticTroubleCodesScreen.kt b/automotive/src/main/java/org/obd/graphs/aa/screen/nav/DiagnosticTroubleCodesScreen.kt index 7a200d54..25904b77 100644 --- a/automotive/src/main/java/org/obd/graphs/aa/screen/nav/DiagnosticTroubleCodesScreen.kt +++ b/automotive/src/main/java/org/obd/graphs/aa/screen/nav/DiagnosticTroubleCodesScreen.kt @@ -66,6 +66,7 @@ import org.obd.graphs.bl.datalogger.dtc.DtcListItem import org.obd.graphs.bl.datalogger.dtc.DtcReadInfoLabels import org.obd.graphs.bl.datalogger.dtc.DtcState import org.obd.graphs.bl.datalogger.dtc.DtcStatusLabels +import org.obd.graphs.bl.datalogger.dtc.EngineStateTracker import org.obd.graphs.bl.datalogger.dtc.PREF_DTC_DESELECTED_MODULES import org.obd.graphs.bl.datalogger.dtc.displayCode import org.obd.graphs.bl.datalogger.dtc.displayDescription @@ -326,7 +327,7 @@ internal class DiagnosticTroubleCodesScreen( } ).build() ) - is DtcListItem.ModuleHeader, is DtcListItem.GoneHeader -> Unit + is DtcListItem.ModuleHeader, is DtcListItem.GoneHeader, is DtcListItem.ModuleClear -> Unit } } }.build() @@ -456,14 +457,20 @@ internal class DiagnosticTroubleCodesScreen( } } -// Clearing erases the codes and their freeze frames for good, so it is never one tap away. +// Clearing erases the codes and their freeze frames for good, so it is never one tap away. With the engine running +// (likely on AA) the message leads with a warning: most modules refuse the clear then. private class DtcClearConfirmationScreen( carContext: CarContext, private val stringProvider: LocalizedStringProvider ) : Screen(carContext) { override fun onGetTemplate(): Template = MessageTemplate - .Builder(stringProvider.getString(R.string.dtc_clear_confirm_message)) + .Builder( + listOfNotNull( + stringProvider.getString(R.string.dtc_engine_running_warning).takeIf { EngineStateTracker.engineRunning() == true }, + stringProvider.getString(R.string.dtc_clear_confirm_message) + ).joinToString("\n\n") + ) .setTitle(stringProvider.getString(R.string.dtc_clear_confirm_title)) .setHeaderAction(Action.BACK) .addAction( diff --git a/automotive/src/main/res/values-pl/strings.xml b/automotive/src/main/res/values-pl/strings.xml index 61857150..6e7eb04e 100644 --- a/automotive/src/main/res/values-pl/strings.xml +++ b/automotive/src/main/res/values-pl/strings.xml @@ -39,6 +39,7 @@ Zgłaszane po skasowaniu: %1$d. Usterka może występować. Skasować kody błędów? Spowoduje to usunięcie zapisanych kodów błędów i danych zamrożonej ramki z pojazdu oraz zeruje monitory gotowości. Tej operacji nie można cofnąć. + Silnik chyba pracuje: większość modułów odmawia kasowania kodów przy pracującym silniku. Wyłącz go, zostaw włączoną stacyjkę. Skasuj Anuluj diff --git a/automotive/src/main/res/values/strings.xml b/automotive/src/main/res/values/strings.xml index 686dcc03..07c2bc5d 100644 --- a/automotive/src/main/res/values/strings.xml +++ b/automotive/src/main/res/values/strings.xml @@ -38,6 +38,7 @@ Still reported after clearing: %1$d. The fault may be present. Clear trouble codes? This erases the stored trouble codes and their freeze frame data from the vehicle and resets the readiness monitors. It cannot be undone. + The engine seems to be running: most modules refuse to clear codes while it runs. Turn it off, leave the ignition on. Clear Cancel diff --git a/datalogger/src/main/java/org/obd/graphs/bl/datalogger/VehicleCapabilitiesManager.kt b/datalogger/src/main/java/org/obd/graphs/bl/datalogger/VehicleCapabilitiesManager.kt index e093b0fc..df0a7074 100644 --- a/datalogger/src/main/java/org/obd/graphs/bl/datalogger/VehicleCapabilitiesManager.kt +++ b/datalogger/src/main/java/org/obd/graphs/bl/datalogger/VehicleCapabilitiesManager.kt @@ -27,7 +27,10 @@ import org.obd.graphs.bl.datalogger.dtc.DtcNewCodesAnnouncer import org.obd.graphs.bl.datalogger.dtc.DtcReadInfo import org.obd.graphs.bl.datalogger.dtc.DtcScan import org.obd.graphs.bl.datalogger.dtc.DtcScanKind +import org.obd.graphs.bl.datalogger.dtc.DtcStoredList +import org.obd.graphs.bl.datalogger.dtc.isSameVehicle import org.obd.graphs.bl.datalogger.dtc.isVinRequest +import org.obd.graphs.bl.datalogger.dtc.mergedOver import org.obd.graphs.bl.datalogger.dtc.vinFrom import org.obd.graphs.preferences.Prefs import org.obd.graphs.sendBroadcastEvent @@ -114,15 +117,24 @@ object VehicleCapabilitiesManager { moduleStatus: Map = emptyMap() ) { val readInfo = DtcReadInfo(timestamp = now, vin = getCurrentVin()) + // The list on screen keeps the modules this read did not target; history records this read alone. + val previous = getDtcReadInfo() + val list = + DtcStoredList(dtc.toList(), scannedModules, moduleStatus).mergedOver( + previous = DtcStoredList(getDiagnosticTroubleCodes(), getDtcScannedModules(), getDtcModuleStatus()), + kind = kind, + sameVehicle = isSameVehicle(previous, readInfo.vin) + ) Prefs.edit().apply { Log.i( LOG_TAG, - "Updating DTC, size: ${dtc.size}, scanned modules: $scannedModules, read info: $readInfo" + "Updating DTC, size: ${dtc.size}, scanned modules: $scannedModules, read info: $readInfo, " + + "kept from the previous list: ${list.scannedModules - scannedModules.toSet()}" ) - putString(PREF_DTC, mapper.writeValueAsString(dtc)) - putString(PREF_DTC_SCANNED_MODULES, mapper.writeValueAsString(scannedModules)) + putString(PREF_DTC, mapper.writeValueAsString(list.codes)) + putString(PREF_DTC_SCANNED_MODULES, mapper.writeValueAsString(list.scannedModules)) putString(PREF_DTC_READ_INFO, mapper.writeValueAsString(readInfo)) - putString(PREF_DTC_MODULE_STATUS, mapper.writeValueAsString(moduleStatus)) + putString(PREF_DTC_MODULE_STATUS, mapper.writeValueAsString(list.moduleStatus)) commit() } val firstScan = DtcHistoryStore.scans(readInfo.vin).isEmpty() diff --git a/datalogger/src/main/java/org/obd/graphs/bl/datalogger/WorkflowOrchestrator.kt b/datalogger/src/main/java/org/obd/graphs/bl/datalogger/WorkflowOrchestrator.kt index 51166d87..b8f06f02 100644 --- a/datalogger/src/main/java/org/obd/graphs/bl/datalogger/WorkflowOrchestrator.kt +++ b/datalogger/src/main/java/org/obd/graphs/bl/datalogger/WorkflowOrchestrator.kt @@ -26,6 +26,7 @@ import org.obd.graphs.bl.datalogger.connectors.ConnectionManager import org.obd.graphs.bl.datalogger.dtc.DtcActionQueue import org.obd.graphs.bl.datalogger.dtc.DtcRequest import org.obd.graphs.bl.datalogger.dtc.DtcScanKind +import org.obd.graphs.bl.datalogger.dtc.EngineStateTracker import org.obd.graphs.bl.datalogger.dtc.dtcClearOutcome import org.obd.graphs.bl.datalogger.dtc.dtcConnectScanModules import org.obd.graphs.bl.datalogger.dtc.dtcScanTargets @@ -84,7 +85,7 @@ internal class WorkflowOrchestrator internal constructor() { } internal val eventsReceiver = EventsReceiver() - private val metricsObserver = MetricsObserver() + private val metricsObserver = MetricsObserver().apply { observe(EngineStateTracker) } private var lifecycle = object : Lifecycle { diff --git a/datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/DtcListItem.kt b/datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/DtcListItem.kt index a6d00df9..a188664d 100644 --- a/datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/DtcListItem.kt +++ b/datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/DtcListItem.kt @@ -53,6 +53,9 @@ sealed class DtcListItem { // has not (healed or cleared). Collapsed by default; expanded is part of the content, so a toggle rebinds it. data class GoneHeader(val codeCount: Int, val expanded: Boolean = false) : DtcListItem() + // "Clear codes in " under an expanded module's last code (phone only, withModuleClearActions). + data class ModuleClear(val module: String) : DtcListItem() + // A plain informational line (no code) - the "no DTC found" empty state, or a scanned module // that reported nothing. Keyed so DiffUtil can tell per-module messages apart. // status: RESPONDED for good news ("no codes"), otherwise why the module has no list. @@ -85,6 +88,7 @@ fun DtcListItem.itemKey(): String = is DtcListItem.DtcRow -> "dtc:${dtc.moduleLabel()}:${dtc.displayCode()}" is DtcListItem.Message -> "message:$key" is DtcListItem.GoneHeader -> "gone-header" + is DtcListItem.ModuleClear -> "clear:$module" } // What a module header shows about its codes, so a collapsed module still says what it holds: @@ -105,7 +109,7 @@ fun List.withModuleSummaries(): List { is DtcListItem.GoneHeader -> header = -1 is DtcListItem.DtcRow -> if (header >= 0 && item.dtc.standardCode.isNotEmpty()) rowsByHeader.getOrPut(header) { mutableListOf() }.add(item) - is DtcListItem.Message -> Unit + is DtcListItem.Message, is DtcListItem.ModuleClear -> Unit } } return mapIndexed { index, item -> @@ -150,6 +154,26 @@ fun List.collapsedBy(expanded: (String) -> Boolean?): List" after the rows of every expanded module with codes that clearable accepts (a module +// of the CAN header table; the default ECU and "Unknown module" cannot be targeted). Call after collapsedBy. +fun List.withModuleClearActions(clearable: (String) -> Boolean): List { + val result = mutableListOf() + var pending: String? = null + + fun flush() { + pending?.let { result.add(DtcListItem.ModuleClear(it)) } + pending = null + } + + forEach { item -> + if (item is DtcListItem.ModuleHeader || item is DtcListItem.GoneHeader) flush() + result.add(item) + if (item is DtcListItem.ModuleHeader && item.expanded && item.codeCount > 0 && clearable(item.module)) pending = item.module + } + flush() + return result +} + // Marks the rows of the list on screen with what the vehicle's history says about them. fun List.markedWith(diff: DtcHistoryDiff): List = map { item -> @@ -369,6 +393,13 @@ fun dtcScanTargets( .filter { it.headerValue.isNotEmpty() && it.requestKey in selected } .distinctBy { it.headerValue.trim().uppercase() } +// The table entry (requestKey) that scans the module codes are tagged with, as dtcScanTargets names it; null for a +// label no entry scans (default ECU, "Unknown module", an entry removed since the read). +fun dtcModuleRequestKey( + mappings: List, + module: String +): String? = dtcScanTargets(mappings, mappings.map { it.requestKey }.toSet()).firstOrNull { it.displayName == module }?.requestKey + // "Engine Speed: 750 rpm" per decoded DID of a snapshot or of the extended data (DiagnosticTroubleCode.details). // PID labels are written for dashboard tiles ("Engine Coolant\nTemp"), so their line breaks are flattened. fun List?.toDidLines( diff --git a/datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/DtcStoredList.kt b/datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/DtcStoredList.kt new file mode 100644 index 00000000..11173765 --- /dev/null +++ b/datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/DtcStoredList.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2019-2026, Tomasz Żebrowski + * + *

Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.obd.graphs.bl.datalogger.dtc + +import org.obd.metrics.api.model.DiagnosticTroubleCode +import org.obd.metrics.command.dtc.DtcModuleStatus + +// The DTC list as stored: codes, the modules the read targeted, how each answered. +data class DtcStoredList( + val codes: List, + val scannedModules: List, + val moduleStatus: Map = emptyMap() +) + +// A read or clear of some modules updates those modules only: the other modules' codes stay from the previous list. +// Without it, clearing one module left a list of that module alone, as if the others had no codes. +// Only a user-started READ / CLEAR of named modules, over a module list of the same vehicle (known VIN). A connect +// scan covers every configured module and replaces the list, so a module removed from the table does not linger. +fun DtcStoredList.mergedOver( + previous: DtcStoredList?, + kind: DtcScanKind, + sameVehicle: Boolean +): DtcStoredList { + if (previous == null || !sameVehicle || kind == DtcScanKind.CONNECT) return this + if (scannedModules.isEmpty() || previous.scannedModules.isEmpty()) return this + val kept = previous.scannedModules.filter { it !in scannedModules }.toSet() + if (kept.isEmpty()) return this + return DtcStoredList( + codes = previous.codes.filter { it.module in kept } + codes, + scannedModules = previous.scannedModules.filter { it in kept } + scannedModules, + moduleStatus = previous.moduleStatus.filterKeys { it in kept } + moduleStatus + ) +} + +// Both reads stamped with the same known vehicle; UNKNOWN never matches, its codes may be another car's. +fun isSameVehicle( + previous: DtcReadInfo?, + vin: String? +): Boolean = previous != null && historyKey(vin) != UNKNOWN_VEHICLE && historyKey(previous.vin) == historyKey(vin) diff --git a/datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/EngineState.kt b/datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/EngineState.kt new file mode 100644 index 00000000..7bc85850 --- /dev/null +++ b/datalogger/src/main/java/org/obd/graphs/bl/datalogger/dtc/EngineState.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2019-2026, Tomasz Żebrowski + * + *

Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.obd.graphs.bl.datalogger.dtc + +import org.obd.graphs.bl.datalogger.MetricsProcessor +import org.obd.graphs.bl.datalogger.Pid +import org.obd.metrics.api.model.ObdMetric +import org.obd.metrics.api.model.VehicleCapabilities + +// Above this the engine runs: idle is ~700 rpm, a stopped engine reads 0. +private const val ENGINE_RUNNING_RPM = 300.0 + +// A reading older than this says nothing: the running query may not poll the engine at all. +const val ENGINE_STATE_MAX_AGE_MS = 10_000L + +data class EngineReading( + val running: Boolean, + val timestamp: Long +) + +// True / false only from a recent reading, null when unknown: the clear pre-check warns on true and stays quiet +// otherwise, never blocking a clear on a guess. +fun EngineReading?.runningAt(now: Long): Boolean? = this?.takeIf { now - it.timestamp <= ENGINE_STATE_MAX_AGE_MS }?.running + +// The engine state as last delivered by the running query: engine speed (OBD-II 01 0C or the GME one) or the vehicle +// status PID. Most modules refuse a clear with the engine running (7F 14 22), so the clear dialogs ask this first. +object EngineStateTracker : MetricsProcessor { + @Volatile + private var last: EngineReading? = null + + private val rpmPids = setOf(Pid.ENGINE_SPEED_PID_ID.id, Pid.EXT_ENGINE_SPEED_PID_ID.id) + + fun engineRunning(now: Long = System.currentTimeMillis()): Boolean? = last.runningAt(now) + + override fun postValue(obdMetric: ObdMetric) { + val running = + when (obdMetric.command.pid.id) { + in rpmPids -> (obdMetric.value as? Number)?.toDouble()?.let { it > ENGINE_RUNNING_RPM } + Pid.VEHICLE_STATUS_PID_ID.id -> (obdMetric.value as? Map<*, *>)?.get("engine.running") as? Boolean + else -> null + } ?: return + last = EngineReading(running, System.currentTimeMillis()) + } + + override fun onRunning(vehicleCapabilities: VehicleCapabilities?) { + last = null + } + + override fun onStopped() { + last = null + } +} diff --git a/datalogger/src/test/java/org/obd/graphs/bl/datalogger/VehicleCapabilitiesManagerTest.kt b/datalogger/src/test/java/org/obd/graphs/bl/datalogger/VehicleCapabilitiesManagerTest.kt index ca806b5a..a3759908 100644 --- a/datalogger/src/test/java/org/obd/graphs/bl/datalogger/VehicleCapabilitiesManagerTest.kt +++ b/datalogger/src/test/java/org/obd/graphs/bl/datalogger/VehicleCapabilitiesManagerTest.kt @@ -373,4 +373,40 @@ class VehicleCapabilitiesManagerTest : TestSetup() { verify(exactly = 0) { org.obd.graphs.sendBroadcastEvent(DATA_LOGGER_DTC_NEW_CODES, any()) } } + + // Stored DTC prefs read back what updateDTC wrote, like the real SharedPreferences. + private fun storeDtcPrefs() { + val store = mutableMapOf() + listOf(PREF_DTC, PREF_DTC_READ_INFO, "pref.datalogger.dtc.scanned_modules", "pref.datalogger.dtc.module_status").forEach { key -> + val value = slot() + every { editor.putString(eq(key), capture(value)) } answers { + store[key] = value.captured + editor + } + every { sharedPrefs.getString(eq(key), any()) } answers { store[key] ?: secondArg() } + } + } + + @Test + fun `clearing one module keeps the other modules' codes on the list, history records the clear alone`() { + storeDtcPrefs() + every { sharedPrefs.getString(eq(PREF_VEHICLE_METADATA), any()) } returns """{"VIN":"ZARFAMAN0K7612345"}""" + val engine = dtc("P0100", "Mass air flow circuit").apply { module = "Engine ECU" } + val abs = dtc("C0035", "Wheel speed sensor").apply { module = "ABS" } + + VehicleCapabilitiesManager.updateDTC(setOf(engine, abs), listOf("Engine ECU", "ABS"), now = 1, kind = DtcScanKind.CONNECT) + VehicleCapabilitiesManager.updateDTC(emptySet(), listOf("Engine ECU"), now = 2, kind = DtcScanKind.CLEAR) + + assertEquals(listOf("C0035"), VehicleCapabilitiesManager.getDiagnosticTroubleCodes().map { it.standardCode }) + assertEquals(setOf("Engine ECU", "ABS"), VehicleCapabilitiesManager.getDtcScannedModules().toSet()) + assertEquals(2L, VehicleCapabilitiesManager.getDtcReadInfo()!!.timestamp) + val clear = DtcHistoryStore.scans("ZARFAMAN0K7612345").last() + assertEquals(listOf("Engine ECU"), clear.scannedModules) + assertTrue(clear.codes.isEmpty()) + + // The next connect scan covers every module and replaces the list. + VehicleCapabilitiesManager.updateDTC(emptySet(), listOf("Engine ECU"), now = 3, kind = DtcScanKind.CONNECT) + assertTrue(VehicleCapabilitiesManager.getDiagnosticTroubleCodes().isEmpty()) + assertEquals(listOf("Engine ECU"), VehicleCapabilitiesManager.getDtcScannedModules()) + } } diff --git a/datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/DtcListItemTest.kt b/datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/DtcListItemTest.kt index b72e4537..39e37676 100644 --- a/datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/DtcListItemTest.kt +++ b/datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/DtcListItemTest.kt @@ -362,4 +362,48 @@ class DtcListItemTest { assertEquals(listOf("BCM", "ABS"), status.unansweredModules()) } + + @Test + fun `an expanded module with codes ends with its clear action, collapsed and clean modules do not`() { + val items = + listOf(dtc("P0100", module = "Engine"), dtc("P0200", module = "Engine"), dtc("C0035", module = "ABS"), dtc("B1000", module = "Unknown")) + .toDtcListItems(listOf("Engine", "ABS", "BCM"), "none", "none for module", "unknown") + .collapsedBy { mapOf("ABS" to false, "BCM" to true)[it] } + .withGoneSection(DtcHistoryDiff(gone = listOf(dtc("P0500", module = "Engine"))), expanded = true) + .withModuleClearActions { it != "Unknown" } + + assertEquals( + listOf( + "header:ABS", + "header:Engine", + "dtc:Engine:P0100", + "dtc:Engine:P0200", + "clear:Engine", + "header:Unknown", + "dtc:Unknown:B1000", + "header:BCM", + "message:BCM", + "gone-header", + "dtc:Engine:P0500" + ), + items.map { it.itemKey() } + ) + } + + @Test + fun `a module label maps back to the table entry that scans it`() { + val mappings = + listOf( + DiagnosticMappingItem(1, "22", "DA10F1", "Engine ECU"), + DiagnosticMappingItem(2, "GME20", "da10f1", "Engine ECU (2.0 GME)"), + DiagnosticMappingItem(3, "ABS", "DA28F1", "ABS"), + DiagnosticMappingItem(4, "BCM", "", "BCM") + ) + assertEquals("22", dtcModuleRequestKey(mappings, "Engine ECU")) + assertEquals("ABS", dtcModuleRequestKey(mappings, "ABS")) + // Never scanned under that name: a header shared with an earlier entry, no header, not in the table. + assertEquals(null, dtcModuleRequestKey(mappings, "Engine ECU (2.0 GME)")) + assertEquals(null, dtcModuleRequestKey(mappings, "BCM")) + assertEquals(null, dtcModuleRequestKey(mappings, "ECU")) + } } diff --git a/datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/DtcStoredListTest.kt b/datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/DtcStoredListTest.kt new file mode 100644 index 00000000..739bcf7e --- /dev/null +++ b/datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/DtcStoredListTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2019-2026, Tomasz Żebrowski + * + *

Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.obd.graphs.bl.datalogger.dtc + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.obd.metrics.api.model.DiagnosticTroubleCode +import org.obd.metrics.command.dtc.DtcModuleStatus + +class DtcStoredListTest { + private fun dtc( + code: String, + module: String + ) = DiagnosticTroubleCode().apply { + standardCode = code + this.module = module + } + + private val previous = + DtcStoredList( + codes = listOf(dtc("P0100", "Engine ECU"), dtc("C0035", "ABS")), + scannedModules = listOf("Engine ECU", "ABS", "BCM"), + moduleStatus = mapOf("Engine ECU" to DtcModuleStatus.RESPONDED, "ABS" to DtcModuleStatus.RESPONDED, "BCM" to DtcModuleStatus.NO_RESPONSE) + ) + + private val engineCleared = DtcStoredList(emptyList(), listOf("Engine ECU"), mapOf("Engine ECU" to DtcModuleStatus.RESPONDED)) + + @Test + fun `clearing one module keeps the other modules' codes and statuses`() { + val merged = engineCleared.mergedOver(previous, DtcScanKind.CLEAR, sameVehicle = true) + + assertEquals(listOf("C0035"), merged.codes.map { it.standardCode }) + assertEquals(setOf("Engine ECU", "ABS", "BCM"), merged.scannedModules.toSet()) + assertEquals(DtcModuleStatus.NO_RESPONSE, merged.moduleStatus["BCM"]) + assertEquals(DtcModuleStatus.RESPONDED, merged.moduleStatus["Engine ECU"]) + } + + @Test + fun `the targeted module's codes come from the new read only`() { + val read = DtcStoredList(listOf(dtc("P0200", "Engine ECU")), listOf("Engine ECU")) + + val merged = read.mergedOver(previous, DtcScanKind.READ, sameVehicle = true) + + assertEquals(setOf("P0200", "C0035"), merged.codes.map { it.standardCode }.toSet()) + } + + @Test + fun `a connect scan, another vehicle or a default read replace the list`() { + assertEquals(engineCleared, engineCleared.mergedOver(previous, DtcScanKind.CONNECT, sameVehicle = true)) + assertEquals(engineCleared, engineCleared.mergedOver(previous, DtcScanKind.CLEAR, sameVehicle = false)) + assertEquals(engineCleared, engineCleared.mergedOver(null, DtcScanKind.CLEAR, sameVehicle = true)) + + val defaultRead = DtcStoredList(listOf(dtc("P0300", "ecu")), emptyList()) + assertEquals(defaultRead, defaultRead.mergedOver(previous, DtcScanKind.READ, sameVehicle = true)) + // A default-ECU list has no modules to keep. + assertEquals(engineCleared, engineCleared.mergedOver(defaultRead, DtcScanKind.CLEAR, sameVehicle = true)) + } + + @Test + fun `only reads stamped with the same known VIN are the same vehicle`() { + val vin = "ZARFAMAN0K7612345" + assertTrue(isSameVehicle(DtcReadInfo(1, vin.lowercase()), vin)) + assertTrue(isSameVehicle(DtcReadInfo(1, NO_VIN), NO_VIN)) + assertFalse(isSameVehicle(DtcReadInfo(1, vin), "ZARFAMAN0K7699999")) + assertFalse(isSameVehicle(DtcReadInfo(1, null), null)) + assertFalse(isSameVehicle(null, vin)) + } +} diff --git a/datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/EngineStateTest.kt b/datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/EngineStateTest.kt new file mode 100644 index 00000000..ebdcde43 --- /dev/null +++ b/datalogger/src/test/java/org/obd/graphs/bl/datalogger/dtc/EngineStateTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2019-2026, Tomasz Żebrowski + * + *

Licensed to the Apache Software Foundation (ASF) under one or more contributor license + * agreements. See the NOTICE file distributed with this work for additional information regarding + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. You may obtain a + * copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.obd.graphs.bl.datalogger.dtc + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.obd.graphs.bl.datalogger.Pid +import org.obd.metrics.api.model.ObdMetric +import org.obd.metrics.command.obd.ObdCommand +import org.obd.metrics.pid.PidDefinition + +class EngineStateTest { + private fun metric( + pid: Pid, + value: Any + ): ObdMetric = + ObdMetric + .builder() + .command(ObdCommand(PidDefinition(pid.id, "1", "01", "0C", "Engine speed", "rpm"))) + .value(value) + .build() + + @Test + fun `a reading counts only while it is recent`() { + val reading = EngineReading(running = true, timestamp = 1_000) + + assertEquals(true, reading.runningAt(1_000 + ENGINE_STATE_MAX_AGE_MS)) + assertNull(reading.runningAt(1_001 + ENGINE_STATE_MAX_AGE_MS)) + assertNull((null as EngineReading?).runningAt(1_000)) + } + + @Test + fun `engine speed and the vehicle status tell whether the engine runs`() { + EngineStateTracker.onStopped() + assertNull(EngineStateTracker.engineRunning()) + + EngineStateTracker.postValue(metric(Pid.ENGINE_SPEED_PID_ID, 780)) + assertEquals(true, EngineStateTracker.engineRunning()) + + EngineStateTracker.postValue(metric(Pid.EXT_ENGINE_SPEED_PID_ID, 0.0)) + assertEquals(false, EngineStateTracker.engineRunning()) + + EngineStateTracker.postValue(metric(Pid.VEHICLE_STATUS_PID_ID, mapOf("engine.running" to true))) + assertEquals(true, EngineStateTracker.engineRunning()) + + // Other PIDs leave the state as it was. + EngineStateTracker.postValue(metric(Pid.COOLANT_TEMP_PID_ID, 90)) + assertEquals(true, EngineStateTracker.engineRunning()) + + EngineStateTracker.onStopped() + assertNull(EngineStateTracker.engineRunning()) + } +} diff --git a/doc/specs/dtc-clear-module.md b/doc/specs/dtc-clear-module.md new file mode 100644 index 00000000..c4d30864 --- /dev/null +++ b/doc/specs/dtc-clear-module.md @@ -0,0 +1,86 @@ +# Spec: clear one module, warn when the engine runs + +2026-10-15 · branch `feat/dtc-clear-module` (ObdGraphs only) · Status: implemented, on-device checks pending + +Roadmap item 5 (`dtc-roadmap.md`). + +## Summary + +**Problem.** +* Clearing one module meant opening the Clear picker and unticking every other module. Afterwards the list held that + module alone: `updateDTC` replaced the stored list with the clear's follow-up read, which covers only the cleared + module, so the other modules' codes vanished from the screen although they were still in the car. +* Most modules refuse a clear with the engine running (`7F 14 22`). The app reported the refusal only afterwards. + +**Scope.** `:datalogger` (list merge, engine state, list item), `:app` (module clear row, dialogs), `:automotive` +(warning in the clear confirmation), EN + PL. + +## Behaviour changes + +| Area | Before | After | +|---|---|---| +| Expanded module with codes (phone) | Header + codes | Also a red "Clear codes in <module>" row under its last code | +| Tap on that row | — | Confirmation: only this module's codes and freeze frames are erased, the app keeps a copy in history, engine off / ignition on, readiness monitors reset if it is the engine module. "Clear" clears that module alone, no picker | +| Clear / read of some modules | The list shows only those modules | Those modules are refreshed; the other modules keep their last codes and statuses | +| Connect scan, default-ECU read, another vehicle, no VIN | List replaced | Unchanged: list replaced | +| Clear (button or module row) with the engine running | Sent; refusal toast afterwards | Phone: "The engine is running" dialog (Clear anyway / Cancel) before the picker; module row: the warning heads its confirmation. AA: the warning heads the clear confirmation | +| Engine state unknown (no engine PID in the running query, reading older than 10 s) | — | No warning; nothing is blocked | +| Default ECU, "Unknown module", a label no table entry scans | — | No clear row | + +## Settings / keys touched + +None new. `pref.datalogger.dtc`, `.scanned_modules`, `.module_status` now hold the merged list after a targeted +read / clear; `pref.datalogger.dtc.read_info` is the time of the latest read, also for kept modules. + +## Implementation + +* `dtc/DtcStoredList.kt`: `DtcStoredList.mergedOver(previous, kind, sameVehicle)`. Merges only for `READ` / `CLEAR` + of named modules over a module list of the same known vehicle (`isSameVehicle`: same `historyKey`, never + `UNKNOWN`). Kept = previous scanned modules not targeted now; their codes and statuses are copied. A `CONNECT` scan + covers every configured module and replaces the list, so a module removed from the table does not linger. +* `VehicleCapabilitiesManager.updateDTC` stores the merged list; the history scan (`DtcHistoryStore.append`) records + the read alone, so "new" / "no longer reported" still compare what was actually scanned. +* `dtc/EngineState.kt`: `EngineStateTracker`, a `MetricsProcessor` registered on `WorkflowOrchestrator`'s + `MetricsObserver`. It keeps the last engine speed (`ENGINE_SPEED_PID_ID` 13 or GME `EXT_ENGINE_SPEED_PID_ID` 7008, + running above 300 rpm) or vehicle status (`engine.running`) reading. `engineRunning()` is true / false only for a + reading younger than `ENGINE_STATE_MAX_AGE_MS` (10 s), else null. Reset on connect and stop. +* `DtcListItem.ModuleClear(module)`, added by `withModuleClearActions(clearable)` after `collapsedBy` (phone only; AA + ignores it). `dtcModuleRequestKey(mappings, module)` maps a code's module label back to the table entry + `dtcScanTargets` scans it with; null means no row. +* Phone: `item_dtc_module_clear.xml` (end-aligned, `cardinal`, bin icon, 48 dp), `ModuleClearViewHolder`; + `DiagnosticTroubleCodePreferenceDialogFragment.clearModule` (confirmation, then `scheduleDTCCleanup(setOf(key))` + through the same `startClear` as the Clear button) and `confirmEngineOff` in front of the Clear picker. +* AA: `DtcClearConfirmationScreen` puts `dtc.engine_running_warning` above the confirmation text. + +## Backward compatibility + +* Lists stored before this change merge normally (they carry scanned modules and read info). +* A list without read info, a default-ECU list or another vehicle's list is replaced as before. + +## Tests + +* `DtcStoredListTest`: one-module clear keeps the others' codes and statuses; the targeted module comes from the new + read only; connect / other vehicle / no previous / default read replace; same-vehicle rule. +* `VehicleCapabilitiesManagerTest`: end to end through prefs: connect with two modules, clear one → the other's code + stays, history records the clear alone; the next connect replaces the list. Fails without the merge (list empty). +* `EngineStateTest`: reading age limit; RPM (both PIDs) and vehicle status set the state, other PIDs do not, stop + resets it. +* `DtcListItemTest`: clear row only under expanded modules with codes that map to a table entry, before the next + header and the gone section; `dtcModuleRequestKey` for shared headers, header-less and unknown labels. + +## Risks and verification checklist + +* The read info says "last read