Skip to content

Commit 105dfc4

Browse files
committed
fix: bluetooth, ..
feat: mg4control profile selector
1 parent 010b609 commit 105dfc4

27 files changed

Lines changed: 443 additions & 25 deletions

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ All notable changes to this project are documented here. Format follows
88

99
### Added
1010

11+
- **"Ask for a profile", an action that hands the choice back to the driver.** "Apply a
12+
profile" decides alone, which is right for a rule that knows the answer and wrong for the
13+
ones that do not: arriving at a charger, the profile to apply depends on what the driver
14+
is about to do, and no condition reads that. The action asks MG4Control to open its own
15+
profile picker, and the driver taps. It needs MG4Control like the profile action does, and
16+
carries the same "when stopped only" mark — MG4Control refuses to put the picker in front
17+
of a moving driver. A refusal is never deferred to the next standstill: a question shown
18+
at the next red light would reach a driver who is no longer asking it.
19+
- **Two Bluetooth conditions that tell a phone in the car from a phone merely near it.**
20+
"Phone connected" is a radio fact and answers yes for a phone left in the house while the
21+
car is parked outside it, which made arrival rules fire on the driveway. **Phone on
22+
board** is true only of a device still connected once the car has driven — a phone that
23+
stayed behind has dropped off the link by then. It is unreadable, and so unevaluable,
24+
until the car has actually moved; rules that must act at ignition should keep using
25+
"phone connected" gated on a vehicle signal. **Hands-free phone** is the one the head
26+
unit routes calls through: it answers from the first second and is what separates two
27+
phones both in range. The diagnostic screen reports both, and says "the car has not
28+
driven yet" rather than blaming the Bluetooth radio.
29+
1130
- **"else if" and "else" cases in a rule.** Cases are tried in the order written and exactly
1231
one runs: the first whose conditions hold, or the "else" when none did. An unreadable
1332
condition stops the rule where it stands instead of falling through to the next case or to

‎MG4Hardware‎

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,16 @@ regeneration, energy saving), **energy** (battery level, charging, charge limit)
226226
(seat/steering heating, media volume, brightness), and **driver assistance** (AEB, ELK,
227227
ACC/TJA, limiter, TSR, overspeed, speed-limit tone, ADAS sound).
228228

229+
Three Bluetooth conditions, and the difference between them is the whole point. **Phone
230+
connected** is a radio fact: it is true of a phone left indoors while the car is parked in
231+
front of the house, which is what made arrival rules fire on the driveway. **Phone on
232+
board** is only true of a device still connected once the car has actually driven, so a
233+
phone that stayed behind drops out of it — at the cost of being unreadable, and therefore
234+
unevaluable, until the first few hundred metres. **Hands-free phone** is the one the head
235+
unit chose to route calls through: available from the first second, and the way to tell two
236+
phones apart when both are in range. A rule that must act at ignition should use *phone
237+
connected* together with a vehicle signal (ignition, speed, not in park) instead.
238+
229239
Actions cover **profile** application, **driving**, **comfort**, **climate** (on/off, target
230240
temperature, A/C, AUTO, recirculation, fan level, front and rear defrosters), **energy**
231241
(charge limit, allow charging, scheduled charging and its window, battery pre-heating),

‎app/src/main/AndroidManifest.xml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
becomes available (via its signature-protected bridge). That is the only capability
99
that needs MG4Control. -->
1010
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
11+
xmlns:tools="http://schemas.android.com/tools"
1112
android:sharedUserId="android.uid.system">
1213

1314
<!-- Vehicle access (Android Automotive / SAIC) — same set MG4Control uses. -->
@@ -33,6 +34,12 @@
3334
<!-- Bluetooth: paired list for the editor, and live connection tracking for rules. -->
3435
<uses-permission android:name="android.permission.BLUETOOTH" />
3536
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
37+
<!-- Reads which phone the head unit made hands-free, for the "hands-free phone"
38+
condition. Granted by the platform signature; without it that one condition
39+
reports as unreadable and nothing else changes. -->
40+
<uses-permission
41+
android:name="android.permission.BLUETOOTH_PRIVILEGED"
42+
tools:ignore="ProtectedPermissions" />
3643
<!-- Requested only when the user chooses "Call a contact". The OEM PBAP client
3744
publishes the paired phone's downloaded phone book through ContactsContract. -->
3845
<uses-permission android:name="android.permission.READ_CONTACTS" />

‎app/src/main/aidl/com/mg4/control/api/IProfileControl.aidl‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,12 @@ interface IProfileControl {
2424
* "detail" String optional.
2525
*/
2626
Bundle applyProfile(String profileId);
27+
28+
/**
29+
* Shows MG4Control's profile picker overlay and leaves the choice to the driver.
30+
* Same result keys as applyProfile. Refused while the car moves or when its speed is
31+
* unreadable — the picker exists to apply a profile — and UNSUPPORTED when MG4Control
32+
* holds no profile to offer.
33+
*/
34+
Bundle showProfilePicker();
2735
}

‎app/src/main/java/com/mg4/tasker/debug/Diagnostics.kt‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ object Diagnostics {
8080
NO_NAVIGATION_APP,
8181
/** No location permission, or no fix recent enough to place the car. */
8282
NO_LOCATION,
83+
/** The car has not moved yet, so no device can be called "on board". */
84+
NOT_DRIVEN_YET,
85+
/** The platform would not say which phone the head unit made hands-free. */
86+
NO_HANDSFREE_INFO,
8387
}
8488

8589
/**
@@ -177,18 +181,27 @@ object Diagnostics {
177181
* them would point whoever reads the report at the wrong subsystem.
178182
*/
179183
private fun unavailableReason(type: ConditionType, snapshot: Snapshot): Reason = when {
184+
// Order matters: with the radio off nothing is knowable, and saying "the car has
185+
// not moved yet" would send the reader looking for a drive instead of a switch.
186+
!snapshot.btAvailable && type in BLUETOOTH_CONDITIONS -> Reason.BLUETOOTH_OFF
187+
type == ConditionType.BT_DEVICE_ONBOARD -> Reason.NOT_DRIVEN_YET
188+
type == ConditionType.BT_DEVICE_HANDSFREE -> Reason.NO_HANDSFREE_INFO
180189
type in BLUETOOTH_CONDITIONS -> Reason.BLUETOOTH_OFF
181190
type == ConditionType.LOCATION_WITHIN -> Reason.NO_LOCATION
182191
!snapshot.bridgeAvailable -> Reason.LAYER_NOT_READY
183192
else -> Reason.NOT_READABLE
184193
}
185194

186-
private val BLUETOOTH_CONDITIONS =
187-
setOf(ConditionType.BT_DEVICE_CONNECTED, ConditionType.ANY_BT_CONNECTED)
195+
private val BLUETOOTH_CONDITIONS = setOf(
196+
ConditionType.BT_DEVICE_CONNECTED,
197+
ConditionType.ANY_BT_CONNECTED,
198+
ConditionType.BT_DEVICE_ONBOARD,
199+
ConditionType.BT_DEVICE_HANDSFREE
200+
)
188201

189202
/** The first check [com.mg4.tasker.vehicle.DirectExecutor] would fail on, in its own order. */
190203
private fun blockingReason(type: ActionType, caps: Capabilities): Reason = when (type) {
191-
ActionType.APPLY_PROFILE -> when {
204+
ActionType.APPLY_PROFILE, ActionType.SHOW_PROFILE_PICKER -> when {
192205
!caps.mg4ControlInstalled -> Reason.NO_MG4CONTROL
193206
!caps.profileBridgeReachable -> Reason.MG4CONTROL_UNREACHABLE
194207
else -> gateReason(type, caps)
@@ -252,7 +265,9 @@ object Diagnostics {
252265
type = type,
253266
op = CompareOp.EQ,
254267
text = when (type) {
255-
ConditionType.BT_DEVICE_CONNECTED -> PROBE_MAC
268+
ConditionType.BT_DEVICE_CONNECTED,
269+
ConditionType.BT_DEVICE_ONBOARD,
270+
ConditionType.BT_DEVICE_HANDSFREE -> PROBE_MAC
256271
ConditionType.LOCATION_WITHIN -> PROBE_POINT
257272
ConditionType.DATE -> java.time.LocalDate.now().toString()
258273
else -> ""
@@ -273,6 +288,11 @@ object Diagnostics {
273288
ConditionType.DATE -> snapshot.localDate
274289
ConditionType.ANY_BT_CONNECTED -> snapshot.btMacs.isNotEmpty()
275290
ConditionType.BT_DEVICE_CONNECTED -> snapshot.btMacs.sorted().joinToString(", ")
291+
// Null never reaches here — an unavailable condition reports its reason instead —
292+
// but the empty set does, and "connected, none of them travelling" is exactly the
293+
// state a user misreads.
294+
ConditionType.BT_DEVICE_ONBOARD -> snapshot.btOnboardMacs?.sorted()?.joinToString(", ")
295+
ConditionType.BT_DEVICE_HANDSFREE -> snapshot.btHandsFreeMacs?.sorted()?.joinToString(", ")
276296
// Where the car thinks it is, which is the only way to tell a radius that is too
277297
// small from a fix that is simply wrong.
278298
ConditionType.LOCATION_WITHIN ->

‎app/src/main/java/com/mg4/tasker/engine/ConditionEvaluator.kt‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,16 @@ object ConditionEvaluator {
5959
// irrelevant here — Bluetooth is context, not a vehicle signal.
6060
if (!s.btAvailable) return ConditionOutcome.UNAVAILABLE
6161
if (c.text.isBlank()) return ConditionOutcome.UNAVAILABLE
62-
val connected = s.btMacs.any { it.equals(c.text, ignoreCase = true) }
63-
return match(connected == c.flag)
62+
// Three questions, not one. "Connected" is a radio fact and answers yes for a phone
63+
// left in a house the car is parked beside; the other two are about which phone is
64+
// actually travelling. A null set is "not knowable yet", never "no".
65+
val macs = when (c.type) {
66+
ConditionType.BT_DEVICE_ONBOARD -> s.btOnboardMacs ?: return ConditionOutcome.UNAVAILABLE
67+
ConditionType.BT_DEVICE_HANDSFREE -> s.btHandsFreeMacs ?: return ConditionOutcome.UNAVAILABLE
68+
else -> s.btMacs
69+
}
70+
val present = macs.any { it.equals(c.text, ignoreCase = true) }
71+
return match(present == c.flag)
6472
}
6573

6674
/**

‎app/src/main/java/com/mg4/tasker/model/Snapshot.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ data class Snapshot(
2020
* phone is paired to the head unit.
2121
*/
2222
val btAvailable: Boolean = true,
23+
/**
24+
* MAC addresses of devices that were still connected once the car had moved — the ones
25+
* that are in it rather than merely in range of it.
26+
*
27+
* null until the car has driven: the answer does not exist yet, and the conditions
28+
* built on it are then unavailable. An empty set is a real answer, "nobody's phone
29+
* came along", and does make those conditions false.
30+
*/
31+
val btOnboardMacs: Set<String>? = null,
32+
/**
33+
* MAC addresses the head unit made active for hands-free, null when unreadable.
34+
*
35+
* Same distinction once more: empty means the head unit chose no phone, null means the
36+
* platform would not say.
37+
*/
38+
val btHandsFreeMacs: Set<String>? = null,
2339
/** Minutes since midnight, local time. */
2440
val minutesOfDay: Int = 0,
2541
/** Current day, java.util.Calendar.MONDAY…SUNDAY values. */

‎app/src/main/java/com/mg4/tasker/service/TaskerVehicleService.kt‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import android.content.BroadcastReceiver
66
import android.content.Context
77
import android.content.Intent
88
import android.content.IntentFilter
9+
import android.os.Handler
10+
import android.os.HandlerThread
911
import android.os.IBinder
1012
import androidx.core.content.ContextCompat
1113
import com.mg4.hardware.AppLogger
@@ -15,6 +17,7 @@ import com.mg4.hardware.VehicleWriteGate
1517
import com.mg4.tasker.model.RuleTrigger
1618
import com.mg4.tasker.store.AppState
1719
import com.mg4.tasker.util.Notifier
20+
import com.mg4.tasker.vehicle.BtOnboard
1821
import com.mg4.tasker.vehicle.BtTracker
1922
import com.mg4.tasker.vehicle.DeferredWrites
2023
import com.mg4.tasker.vehicle.ProfileBridge
@@ -36,6 +39,14 @@ class TaskerVehicleService : Service() {
3639

3740
companion object {
3841
private const val TAG = "MG4Tasker.Vehicle"
42+
/**
43+
* How often the moving car is asked which phones are still with it.
44+
*
45+
* Two binder reads (speed, connected devices) — cheap enough at this pace that the
46+
* drive is sampled several times before the first junction, and rare enough that a
47+
* long motorway leg costs nothing measurable.
48+
*/
49+
private const val ONBOARD_SAMPLE_MS = 15_000L
3950
private const val HARDKEY_ACTION = "com.saic.keyevent.hardkey.report"
4051
private const val SYSTEMUI_HARDKEY_ACTION = "com.android.systemui.ACTION_HARD_KEY_EVENT"
4152
const val HARDKEY_PERMISSION = "com.mg4.tasker.permission.RECEIVE_HARDKEY"
@@ -65,6 +76,19 @@ class TaskerVehicleService : Service() {
6576
@Volatile
6677
private var lastTrigger: RuleTrigger? = null
6778

79+
/**
80+
* Off the main thread: sampling reads the car and the Bluetooth stack over binder, and
81+
* this service runs in the same process as the UI.
82+
*/
83+
private val onboardThread = HandlerThread("mg4-tasker-onboard")
84+
private lateinit var onboardHandler: Handler
85+
private val onboardSampler = object : Runnable {
86+
override fun run() {
87+
BtOnboard.sample(this@TaskerVehicleService)
88+
onboardHandler.postDelayed(this, ONBOARD_SAMPLE_MS)
89+
}
90+
}
91+
6892
override fun onBind(intent: Intent?): IBinder? = null
6993

7094
override fun onCreate() {
@@ -97,9 +121,17 @@ class TaskerVehicleService : Service() {
97121
// The gate resets to standstill-only on every process start; hand it back the
98122
// threshold the user chose before any rule can be evaluated.
99123
AppState.applyWriteThreshold(this)
124+
onboardThread.start()
125+
onboardHandler = Handler(onboardThread.looper)
100126
registerBtReceiver()
101127
registerHardkeyReceiver()
102128
registerIgnitionListener()
129+
// START_STICKY means this service can be recreated mid-drive. Waiting for the next
130+
// IGNITION_ON would then leave the whole drive unsampled, and every "phone on board"
131+
// rule unevaluable until the car had been switched off and on again.
132+
if (MG4Hardware.getCurrentIgnitionState() == MG4Hardware.CarIgnitionItem.RUN) {
133+
startOnboardSampling()
134+
}
103135
warnIfMG4ControlPresent()
104136
isRunning = true
105137
}
@@ -112,6 +144,17 @@ class TaskerVehicleService : Service() {
112144
ignitionListener?.let { MG4Hardware.unregisterVehicleConditionListener(it) }
113145
btReceiver?.let { runCatching { unregisterReceiver(it) } }
114146
hardkeyReceiver?.let { runCatching { unregisterReceiver(it) } }
147+
onboardHandler.removeCallbacks(onboardSampler)
148+
onboardThread.quitSafely()
149+
}
150+
151+
private fun startOnboardSampling() {
152+
onboardHandler.removeCallbacks(onboardSampler)
153+
onboardHandler.post(onboardSampler)
154+
}
155+
156+
private fun stopOnboardSampling() {
157+
onboardHandler.removeCallbacks(onboardSampler)
115158
}
116159

117160
/**
@@ -136,6 +179,15 @@ class TaskerVehicleService : Service() {
136179
// The drive is over: writes held back for a red light that never came do not
137180
// belong to the next one.
138181
if (trigger == RuleTrigger.IGNITION_OFF) DeferredWrites.clear()
182+
// The onboard set is cleared when the next drive starts, not when this one
183+
// ends: the IGNITION_OFF rules are precisely the ones that want to know
184+
// whose phone made the trip, and they evaluate on a thread of their own.
185+
if (trigger == RuleTrigger.IGNITION_ON) {
186+
BtOnboard.reset()
187+
startOnboardSampling()
188+
} else {
189+
stopOnboardSampling()
190+
}
139191
if (AppState.isAutomationEnabled(this)) {
140192
AppLogger.i(TAG, "Ignition $state → evaluating ${trigger.name} rules")
141193
thread(name = "mg4-tasker-cycle") { RuleCycle.run(this, trigger.name) }

‎app/src/main/java/com/mg4/tasker/ui/BranchEditorActivity.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.mg4.tasker.model.Action
1919
import com.mg4.tasker.model.Branch
2020
import com.mg4.tasker.model.Condition
2121
import com.mg4.tasker.model.MatchMode
22+
import com.mg4.hardware.catalog.ActionGroup
2223
import com.mg4.hardware.catalog.ActionType
2324
import com.mg4.hardware.catalog.ValueKind
2425
import com.mg4.tasker.util.BtDevices
@@ -170,7 +171,7 @@ class BranchEditorActivity : AppCompatActivity() {
170171
}
171172
binding.addActionButton.setOnClickListener {
172173
CatalogPicker.pickAction(this, firmware) { type ->
173-
if (type == ActionType.APPLY_PROFILE && mg4ControlDetected) {
174+
if (type.group == ActionGroup.PROFILE && mg4ControlDetected) {
174175
actionAwaitingProfileWarning = type
175176
profileWarning.launch(ProfileAutomationWarningActivity.intent(this))
176177
} else {

0 commit comments

Comments
 (0)