Skip to content

Commit f53bac0

Browse files
malysclaude
andcommitted
feat(rules): run a rule when the car reaches or leaves a place
A LOCATION trigger samples the rule's "near a place" conditions every 5 s while the ignition is in RUN and runs the rule once on a confirmed false -> true edge, with a silent baseline like the P trigger. Only the rule whose place was reached runs. GPS subscription cadence 60 s -> 5 s. Runs while moving: vehicle writes still take the standstill gate, and a position rule must name a place and may not ask a question or open the profile picker (refused at save and import). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 81058d6 commit f53bac0

20 files changed

Lines changed: 269 additions & 12 deletions

File tree

‎AGENTS.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ switch-off trigger reads the other end of that same stream — no second listene
5757
Gear callbacks are not portable across the supported firmwares, so the P trigger samples
5858
`EVHardware.isVehicleInPark()` every 500 ms only while ignition is RUN and fires only on a
5959
confirmed non-P → P transition. Its first readable sample is a silent baseline, so service
60-
recreation while already parked never fabricates an event. Physical buttons are conditions
60+
recreation while already parked never fabricates an event. The position trigger shares that sampling lifecycle (every 5 s, RUN only):
61+
`LocationTriggerDetector` fires per "near a place" condition on a confirmed false → true edge,
62+
with the same silent baseline, and `RuleCycle` runs only the rule whose place it was. It runs
63+
while moving, so `Rule.positionTriggerIsSound` refuses screen-taking actions (question,
64+
profile picker) at save and import. Physical buttons are conditions
6165
(never a `RuleTrigger`): a rule containing one is
6266
addressed by that event and excluded from vehicle-trigger cycles. EVHardware owns the OEM keycode
6367
catalogue and short/long-press state machine; the app only receives the broadcast and feeds

‎CHANGELOG.md‎

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

77
## [Unreleased]
88

9+
### Added
10+
11+
- **Position trigger.** A rule can now run when the car reaches a place, instead of only at
12+
start, P or switch-off. While the ignition is on, its "near a place" conditions are checked
13+
every 5 s; the rule runs once when one turns true (arriving — or leaving, for "not near"),
14+
never again while the car stays there, and a drive that starts inside the zone does not
15+
count as an arrival. Vehicle writes still take the standstill gate, so on the move they are
16+
refused and shown as such. Such a rule must contain a place condition and may not ask a
17+
question or open the profile picker, since it runs while driving.
18+
19+
### Changed
20+
21+
- The GPS subscription updates every 5 s instead of every 60 s, so an arrival is noticed
22+
within a few dozen metres.
23+
924
## [2.12.0] - 2026-09-23
1025

1126
### Added

‎app/src/main/java/com/evsuite/tasker/model/Rule.kt‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@ enum class MatchMode { ALL, ANY }
2020
* At [IGNITION_OFF] the car is powering down. Settings that persist (charge limit, door
2121
* locks, windows) land; anything the vehicle drops with the ignition may not, and the
2222
* history reports what each action returned rather than assuming.
23+
*
24+
* [LOCATION] is sampled while the ignition is in RUN: the rule runs once when one of its
25+
* "near a place" conditions turns true (arriving, or leaving when the condition says "not
26+
* near"), never repeatedly while the car stays where it is. See [positionTriggerIsSound].
2327
*/
24-
enum class RuleTrigger { IGNITION_ON, GEAR_PARK, IGNITION_OFF }
28+
enum class RuleTrigger { IGNITION_ON, GEAR_PARK, IGNITION_OFF, LOCATION }
2529

2630
/**
2731
* One configured condition.
@@ -239,8 +243,24 @@ data class Rule(
239243
*/
240244
val buttonAddressingIsSound: Boolean get() = !hasPhysicalButtonCondition ||
241245
(otherwise.isEmpty() && branches.all { branch -> branch.conditions.any { it.type.eventDriven } })
246+
247+
/**
248+
* Whether a position-triggered rule can fire, and fire safely.
249+
*
250+
* The event *is* a "near a place" condition turning true, so a rule without one would
251+
* never run. And it runs mostly while the car moves: an action that puts a question or a
252+
* picker in front of the driver is refused, per the suite rule against driver-facing
253+
* overlays while moving. Refused at save and import, like [buttonAddressingIsSound].
254+
*/
255+
val positionTriggerIsSound: Boolean get() = firesOn != RuleTrigger.LOCATION ||
256+
hasPhysicalButtonCondition ||
257+
(branches.any { b -> b.conditions.any { it.type == ConditionType.LOCATION_WITHIN } } &&
258+
(branches.flatMap { it.actions } + otherwise).none { it.type in SCREEN_ACTIONS })
242259
}
243260

261+
/** Actions that wait for the driver's hands on the screen. */
262+
private val SCREEN_ACTIONS = setOf(ActionType.ASK_CONFIRM, ActionType.SHOW_PROFILE_PICKER)
263+
244264
/** Whether a list of actions changes anything, or is only waiting. */
245265
private fun List<Action>.doesSomething(): Boolean = any { it.type != ActionType.DELAY }
246266

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

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.evsuite.tasker.util.BtDevices
2121
import com.evsuite.tasker.util.BtMessaging
2222
import com.evsuite.tasker.util.CarLocation
2323
import com.evsuite.tasker.util.DriveClock
24+
import com.evsuite.tasker.util.LocationTriggerDetector
2425
import com.evsuite.tasker.util.Notifier
2526
import com.evsuite.tasker.util.ParkTriggerDetector
2627
import com.evsuite.tasker.vehicle.BtOnboard
@@ -56,6 +57,12 @@ class TaskerVehicleService : Service() {
5657
private const val ONBOARD_SAMPLE_MS = 15_000L
5758
/** Gear has no portable push callback on every supported generation. Poll only in RUN. */
5859
private const val PARK_SAMPLE_MS = 500L
60+
/**
61+
* How often position rules are checked while in RUN. At 50 km/h the car covers ~70 m
62+
* between two looks — inside any radius worth writing — and each look is one cached
63+
* fix and one read of the rule store, no vehicle binder call.
64+
*/
65+
private const val LOCATION_SAMPLE_MS = 5_000L
5966
private const val HARDKEY_ACTION = "com.saic.keyevent.hardkey.report"
6067
private const val SYSTEMUI_HARDKEY_ACTION = "com.android.systemui.ACTION_HARD_KEY_EVENT"
6168
const val HARDKEY_PERMISSION = "com.evsuite.tasker.permission.RECEIVE_HARDKEY"
@@ -95,6 +102,7 @@ class TaskerVehicleService : Service() {
95102
private var lastTrigger: RuleTrigger? = null
96103

97104
private val parkTrigger = ParkTriggerDetector()
105+
private val locationTrigger = LocationTriggerDetector()
98106

99107
/** Stops an in-flight park sample from scheduling itself again after ignition-off. */
100108
@Volatile
@@ -127,6 +135,21 @@ class TaskerVehicleService : Service() {
127135
}
128136
}
129137

138+
/** Shares the park sampler's lifecycle and thread: RUN only, detector reset at each edge. */
139+
private val locationSampler = object : Runnable {
140+
override fun run() {
141+
if (!vehicleRunning) return
142+
val rules = RuleStore(this@TaskerVehicleService).getAll().filter {
143+
it.enabled && !it.hasPhysicalButtonCondition && it.firesOn == RuleTrigger.LOCATION
144+
}
145+
val fix = CarLocation.lastKnown(applicationContext)
146+
for (id in locationTrigger.sample(rules, fix)) {
147+
runRulesFor(RuleTrigger.LOCATION, "Position condition turned true", id)
148+
}
149+
if (vehicleRunning) onboardHandler.postDelayed(this, LOCATION_SAMPLE_MS)
150+
}
151+
}
152+
130153
override fun onBind(intent: Intent?): IBinder? = null
131154

132155
override fun onCreate() {
@@ -213,6 +236,7 @@ class TaskerVehicleService : Service() {
213236
vehicleRunning = false
214237
onboardHandler.removeCallbacks(onboardSampler)
215238
onboardHandler.removeCallbacks(parkSampler)
239+
onboardHandler.removeCallbacks(locationSampler)
216240
onboardThread.quitSafely()
217241
}
218242

@@ -229,24 +253,33 @@ class TaskerVehicleService : Service() {
229253
if (vehicleRunning) return
230254
vehicleRunning = true
231255
onboardHandler.removeCallbacks(parkSampler)
256+
onboardHandler.removeCallbacks(locationSampler)
232257
onboardHandler.post {
233258
parkTrigger.reset()
234-
if (vehicleRunning) parkSampler.run()
259+
locationTrigger.reset()
260+
if (vehicleRunning) {
261+
parkSampler.run()
262+
locationSampler.run()
263+
}
235264
}
236265
}
237266

238267
private fun stopParkSampling() {
239268
if (!vehicleRunning) return
240269
vehicleRunning = false
241270
onboardHandler.removeCallbacks(parkSampler)
271+
onboardHandler.removeCallbacks(locationSampler)
242272
// Keep detector mutation on its sampling thread.
243-
onboardHandler.post { parkTrigger.reset() }
273+
onboardHandler.post {
274+
parkTrigger.reset()
275+
locationTrigger.reset()
276+
}
244277
}
245278

246-
private fun runRulesFor(trigger: RuleTrigger, event: String) {
279+
private fun runRulesFor(trigger: RuleTrigger, event: String, ruleId: String? = null) {
247280
if (AppState.isAutomationEnabled(this)) {
248-
AppLogger.i(TAG, "$event → evaluating ${trigger.name} rules")
249-
thread(name = "mg4-tasker-cycle") { RuleCycle.run(this, trigger.name) }
281+
AppLogger.i(TAG, "$event → evaluating ${trigger.name} rules${ruleId?.let { " ($it)" }.orEmpty()}")
282+
thread(name = "mg4-tasker-cycle") { RuleCycle.run(this, trigger.name, ruleId = ruleId) }
250283
} else {
251284
AppLogger.i(TAG, "$event but automation disabled — ignored")
252285
}

‎app/src/main/java/com/evsuite/tasker/store/RuleTransfer.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ object RuleTransfer {
224224
// and a rule addressed by a button must name one in every case it can run.
225225
if (!rule.isComplete()) return Result.Invalid(Reason.MALFORMED)
226226
if (!rule.buttonAddressingIsSound) return Result.Invalid(Reason.MALFORMED)
227+
if (!rule.positionTriggerIsSound) return Result.Invalid(Reason.MALFORMED)
227228

228229
return Result.Ok(listOf(rule))
229230
}

‎app/src/main/java/com/evsuite/tasker/ui/HistoryFragment.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ class HistoryFragment : Fragment() {
8989
DeferredWrites.TRIGGER -> R.string.history_trigger_deferred
9090
RuleTrigger.GEAR_PARK.name -> R.string.history_trigger_park
9191
RuleTrigger.IGNITION_OFF.name -> R.string.history_trigger_ignition_off
92+
RuleTrigger.LOCATION.name -> R.string.history_trigger_location
9293
RuleCycle.PHYSICAL_BUTTON -> R.string.history_trigger_button
9394
else -> R.string.history_trigger_ignition
9495
}

‎app/src/main/java/com/evsuite/tasker/ui/RuleEditorActivity.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ class RuleEditorActivity : AppCompatActivity() {
105105
binding.triggerGroup.check(when (existing?.firesOn) {
106106
RuleTrigger.GEAR_PARK -> R.id.triggerGearPark
107107
RuleTrigger.IGNITION_OFF -> R.id.triggerIgnitionOff
108+
RuleTrigger.LOCATION -> R.id.triggerLocation
108109
else -> R.id.triggerIgnitionOn
109110
})
110111

@@ -276,6 +277,7 @@ class RuleEditorActivity : AppCompatActivity() {
276277
trigger = when (binding.triggerGroup.checkedButtonId) {
277278
R.id.triggerGearPark -> RuleTrigger.GEAR_PARK
278279
R.id.triggerIgnitionOff -> RuleTrigger.IGNITION_OFF
280+
R.id.triggerLocation -> RuleTrigger.LOCATION
279281
else -> RuleTrigger.IGNITION_ON
280282
},
281283
conditions = first.conditions,
@@ -301,6 +303,10 @@ class RuleEditorActivity : AppCompatActivity() {
301303
if (!rule.buttonAddressingIsSound) {
302304
toastLong(getString(R.string.editor_button_cases)); return
303305
}
306+
// Without a place the trigger never fires; with a question it would ask a moving driver.
307+
if (!rule.positionTriggerIsSound) {
308+
toastLong(getString(R.string.editor_location_trigger_rules)); return
309+
}
304310
// Said now rather than through a wait cut short in the history after the drive.
305311
if (rule.totalDelayMs > com.evsuite.tasker.model.DELAY_BUDGET_MS) {
306312
toastLong(

‎app/src/main/java/com/evsuite/tasker/ui/RulesFragment.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ class RulesFragment : Fragment() {
174174
else when (current.firesOn) {
175175
com.evsuite.tasker.model.RuleTrigger.GEAR_PARK -> R.string.editor_trigger_park
176176
com.evsuite.tasker.model.RuleTrigger.IGNITION_OFF -> R.string.editor_trigger_off
177+
com.evsuite.tasker.model.RuleTrigger.LOCATION -> R.string.editor_trigger_location
177178
else -> R.string.editor_trigger_on
178179
}
179180
)

‎app/src/main/java/com/evsuite/tasker/util/CarLocation.kt‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ object CarLocation {
2929
private const val MAX_AGE_MS = 30 * 60 * 1000L
3030

3131
/**
32-
* Cadence of the service's subscription. Far below [MAX_AGE_MS], so the cache is never
33-
* the reason a condition is unanswerable, and far above a navigation app's, because
34-
* nothing here needs to know which lane the car is in.
32+
* Cadence of the service's subscription. Matches the position trigger's sampling: at
33+
* 60 s the fix jumped ~800 m between updates at 50 km/h and an arrival was noticed a
34+
* minute late or, through a small radius, not at all. Still well above a navigation
35+
* app's, because nothing here needs to know which lane the car is in.
3536
*/
36-
private const val TRACK_INTERVAL_MS = 60_000L
37+
private const val TRACK_INTERVAL_MS = 5_000L
3738

3839
/** How long an inactive subscription may stay inactive before the watchdog re-arms it. */
3940
const val TRACK_RETRY_INTERVAL_MS = 60_000L
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.evsuite.tasker.util
2+
3+
import com.evsuite.hardware.catalog.ConditionType
4+
import com.evsuite.tasker.engine.ConditionEvaluator
5+
import com.evsuite.tasker.model.ConditionOutcome
6+
import com.evsuite.tasker.model.Rule
7+
import com.evsuite.tasker.model.Snapshot
8+
9+
/**
10+
* Detects a "near a place" condition turning true, for the position trigger.
11+
*
12+
* Same contract as [ParkTriggerDetector], per condition: the first readable sample is a
13+
* silent baseline (a drive that starts at home must not "arrive" there), an unreadable one
14+
* changes nothing, and only a confirmed false → true transition fires. The key carries the
15+
* condition's own point, radius and sense, so editing a place starts a fresh baseline
16+
* instead of comparing against the old one.
17+
*/
18+
class LocationTriggerDetector {
19+
private val lastMatch = HashMap<String, Boolean>()
20+
21+
/** @return the ids of [rules] one of whose position conditions has just turned true. */
22+
fun sample(rules: List<Rule>, fix: CarLocation.Fix?): List<String> {
23+
val snapshot = Snapshot(latitude = fix?.latitude, longitude = fix?.longitude)
24+
val seen = HashSet<String>()
25+
val fired = rules.filter { rule ->
26+
rule.branches.flatMap { it.conditions }
27+
.filter { it.type == ConditionType.LOCATION_WITHIN }
28+
.mapIndexed { i, c ->
29+
val key = "${rule.id}#$i#${c.text}#${c.number}#${c.flag}"
30+
seen += key
31+
when (ConditionEvaluator.evaluate(c, snapshot)) {
32+
ConditionOutcome.UNAVAILABLE -> false
33+
ConditionOutcome.MATCH -> lastMatch.put(key, true) == false
34+
else -> { lastMatch[key] = false; false }
35+
}
36+
}
37+
.any { it }
38+
}.map { it.id }
39+
lastMatch.keys.retainAll(seen)
40+
return fired
41+
}
42+
43+
fun reset() {
44+
lastMatch.clear()
45+
}
46+
}

0 commit comments

Comments
 (0)