Skip to content

Commit 4615bf5

Browse files
malysclaude
andcommitted
feat: a wait between two actions, and an order to put it in
The engine runs a rule's actions sequentially, so the wait belongs there rather than in the executor: nothing is written, there is no verdict to obtain from the vehicle, and the engine is the only place that can hold the line between two actions. It reuses the injectable sleep, so tests do not actually wait. Actions can now be reordered in the editor, which is what makes the wait usable — a pause only means something if the user can say what it comes between. Conditions keep no arrows: they are all evaluated whatever their order. A rule made only of waits is incomplete. It holds the cycle for its duration and changes nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2daa436 commit 4615bf5

15 files changed

Lines changed: 240 additions & 6 deletions

File tree

‎CHANGELOG.md‎

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

99
### Added
1010

11+
- **Wait** action (1 to 60 seconds), and reordering of a rule's actions with the arrows
12+
on each row. Actions run one after the other in the order shown, so a wait placed
13+
between two of them lets the first write land before the second is asked for
14+
(climate on, then fan level). A rule made only of waits is refused at save time.
1115
- Configuration tab. The speed threshold, the language and the rules file
1216
(export / import) moved there from the rules pane; the Rules tab is now the rule
1317
list and nothing else.

‎MG4Hardware‎

‎README.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,14 @@ Gate refusals are now kept and re-attempted at the first standstill. Three limit
184184
Transient action failures are different: each failed action is retried independently up to
185185
three times with exponential backoff (250 ms, then 500 ms). Actions that already succeeded
186186
are never replayed.
187+
188+
A rule's actions run **one after the other, in the order shown in the editor**, and each one
189+
is finished before the next starts. That order is part of the rule: the arrows on every
190+
action row move it up or down. Where one write must land before the next is asked for —
191+
switching the climate on, then setting the fan level — the **Wait** action (1 to 60 seconds)
192+
holds the sequence in between. It touches nothing in the car, and appears in the history at
193+
its place in the sequence. A rule made only of waits is refused at save time: it would hold
194+
the cycle and change nothing.
187195
- **15 minutes, and never across an ignition cycle.** A rule that fired for this drive
188196
belongs to this drive. At switch-off the queue is dropped, not applied: the car is stopped
189197
then, which is exactly what makes it the wrong moment to change the setting the next
@@ -215,7 +223,7 @@ temperature, A/C, AUTO, recirculation, fan level, front and rear defrosters), **
215223
(charge limit, allow charging, scheduled charging and its window, battery pre-heating),
216224
**audio** (volume, the fine controls, play the radio), **driver assistance**, and **system**
217225
(launch an app, show a message, speak through the head unit's text-to-speech engine,
218-
navigate to a destination, call a number). ADAS state — AEB, ELK, ACC/TJA, TSR, overspeed and
226+
navigate to a destination, call a number, wait). ADAS state — AEB, ELK, ACC/TJA, TSR, overspeed and
219227
so on — is fully covered as gated actions.
220228

221229
**Near a place** compares the car's last known fix with a point stored in the rule and a

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.mg4.tasker.bridge.BridgeContract
44
import com.mg4.tasker.model.Action
55
import com.mg4.tasker.model.ActionResult
66
import com.mg4.tasker.model.ConditionOutcome
7+
import com.mg4.hardware.catalog.ActionType
78
import com.mg4.hardware.catalog.ConditionType
89
import com.mg4.tasker.model.EngineRun
910
import com.mg4.tasker.model.MatchMode
@@ -75,10 +76,30 @@ class RuleEngine(
7576
}
7677
}
7778

78-
val results = rule.actions.map { executeWithBackoff(it) }
79+
// Sequential on purpose: two writes to the same subsystem sent at once are answered
80+
// from the state the car had before either landed, and the user's order is the order
81+
// they meant — a [ActionType.DELAY] between two of them only means anything if what
82+
// follows it really waits.
83+
val results = rule.actions.map {
84+
if (it.type == ActionType.DELAY) pause(it) else executeWithBackoff(it)
85+
}
7986
return RuleRun(rule.id, rule.name, RuleOutcome.FIRED, results)
8087
}
8188

89+
/**
90+
* The wait, run here rather than in the executor.
91+
*
92+
* Nothing is written, so there is no verdict to obtain from the vehicle; and the engine
93+
* is what runs the actions one after the other, so it is also what can hold the line
94+
* between two of them. Reusing the injectable [sleep] keeps tests from actually waiting.
95+
*/
96+
private fun pause(action: Action): ActionResult {
97+
val spec = ActionType.DELAY.spec
98+
val seconds = action.number.coerceIn(spec.min, spec.max)
99+
sleep(seconds * 1000L)
100+
return ActionResult(ActionType.DELAY, true, BridgeContract.VERDICT_ALLOWED, "$seconds s")
101+
}
102+
82103
private fun executeWithBackoff(action: Action): ActionResult {
83104
var delay = initialDelayMs
84105
var attempt = 1

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,12 @@ data class Rule(
9191
* A rule with no condition would apply on every start without the user asking; a rule
9292
* with no action does nothing. Both are refused at save time rather than silently
9393
* ignored at run time.
94+
*
95+
* A rule made only of waits counts as having no action: it holds the cycle for its
96+
* duration and changes nothing.
9497
*/
95-
fun isComplete(): Boolean = conditions.isNotEmpty() && actions.isNotEmpty()
98+
fun isComplete(): Boolean =
99+
conditions.isNotEmpty() && actions.any { it.type != ActionType.DELAY }
96100

97101
/** [trigger], defaulting to vehicle start — what every rule written before this did. */
98102
val firesOn: RuleTrigger get() = trigger ?: RuleTrigger.IGNITION_ON

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,23 +313,48 @@ class RuleEditorActivity : AppCompatActivity() {
313313
}
314314
}
315315
},
316-
onRemove = { actions.removeAt(index); renderActions() }
316+
onRemove = { actions.removeAt(index); renderActions() },
317+
// Actions run in the order shown, so the order is part of the rule: a wait
318+
// is only useful where the user puts it.
319+
reorderable = true,
320+
canMoveUp = index > 0,
321+
canMoveDown = index < actions.lastIndex,
322+
onMoveUp = { move(index, index - 1) },
323+
onMoveDown = { move(index, index + 1) }
317324
)
318325
}
319326
}
320327

328+
private fun move(from: Int, to: Int) {
329+
actions.add(to, actions.removeAt(from))
330+
renderActions()
331+
}
332+
321333
private fun addRow(
322334
container: android.view.ViewGroup,
323335
label: String,
324336
gated: Boolean,
325337
onEdit: () -> Unit,
326-
onRemove: () -> Unit
338+
onRemove: () -> Unit,
339+
reorderable: Boolean = false,
340+
canMoveUp: Boolean = false,
341+
canMoveDown: Boolean = false,
342+
onMoveUp: () -> Unit = {},
343+
onMoveDown: () -> Unit = {}
327344
) {
328345
val row = ItemEditorRowBinding.inflate(LayoutInflater.from(this), container, false)
329346
row.rowLabel.text = label
330347
row.rowGated.visibility = if (gated) View.VISIBLE else View.GONE
331348
row.rowClickArea.setOnClickListener { onEdit() }
332349
row.rowRemove.setOnClickListener { onRemove() }
350+
// Kept in place and disabled at the ends rather than hidden: buttons that disappear
351+
// move every other row's controls under the finger already reaching for them.
352+
row.rowUp.visibility = if (reorderable) View.VISIBLE else View.GONE
353+
row.rowDown.visibility = if (reorderable) View.VISIBLE else View.GONE
354+
row.rowUp.isEnabled = canMoveUp
355+
row.rowDown.isEnabled = canMoveDown
356+
row.rowUp.setOnClickListener { onMoveUp() }
357+
row.rowDown.setOnClickListener { onMoveDown() }
333358
container.addView(row.root)
334359
}
335360

‎app/src/main/res/layout/item_editor_row.xml‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,40 @@
4848
android:visibility="gone" />
4949
</LinearLayout>
5050

51+
<!-- Reordering: shown for actions, which run in the order listed, and hidden for
52+
conditions, which are all evaluated regardless of order. -->
53+
<com.google.android.material.button.MaterialButton
54+
android:id="@+id/rowUp"
55+
style="@style/Widget.Material3.Button.TextButton"
56+
android:layout_width="wrap_content"
57+
android:layout_height="wrap_content"
58+
android:minWidth="@dimen/touch_target"
59+
android:minHeight="@dimen/touch_target"
60+
android:insetLeft="0dp"
61+
android:insetRight="0dp"
62+
android:paddingStart="@dimen/spacing_sm"
63+
android:paddingEnd="@dimen/spacing_sm"
64+
android:textSize="@dimen/text_body"
65+
android:text="@string/editor_move_up_glyph"
66+
android:contentDescription="@string/editor_move_up"
67+
android:visibility="gone" />
68+
69+
<com.google.android.material.button.MaterialButton
70+
android:id="@+id/rowDown"
71+
style="@style/Widget.Material3.Button.TextButton"
72+
android:layout_width="wrap_content"
73+
android:layout_height="wrap_content"
74+
android:minWidth="@dimen/touch_target"
75+
android:minHeight="@dimen/touch_target"
76+
android:insetLeft="0dp"
77+
android:insetRight="0dp"
78+
android:paddingStart="@dimen/spacing_sm"
79+
android:paddingEnd="@dimen/spacing_sm"
80+
android:textSize="@dimen/text_body"
81+
android:text="@string/editor_move_down_glyph"
82+
android:contentDescription="@string/editor_move_down"
83+
android:visibility="gone" />
84+
5185
<com.google.android.material.button.MaterialButton
5286
android:id="@+id/rowRemove"
5387
style="@style/Widget.Material3.Button.TextButton"

‎app/src/main/res/values-de/strings.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,6 @@
239239
<string name="value_editor_instruction">Wählen Sie den Wert für diese Regel.</string>
240240
<string name="editor_trigger_on">Fahrzeugstart</string>
241241
<string name="editor_trigger_off">Fahrzeug aus</string>
242+
<string name="editor_move_up">Nach oben</string>
243+
<string name="editor_move_down">Nach unten</string>
242244
</resources>

‎app/src/main/res/values-es/strings.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,6 @@
239239
<string name="value_editor_instruction">Elija el valor que se usará en esta regla.</string>
240240
<string name="editor_trigger_on">Al arrancar</string>
241241
<string name="editor_trigger_off">Al apagar</string>
242+
<string name="editor_move_up">Subir</string>
243+
<string name="editor_move_down">Bajar</string>
242244
</resources>

‎app/src/main/res/values-fr/strings.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,4 +256,6 @@
256256
<string name="value_editor_instruction">Choisissez la valeur à utiliser dans cette règle.</string>
257257
<string name="editor_trigger_on">Au démarrage</string>
258258
<string name="editor_trigger_off">À l’extinction</string>
259+
<string name="editor_move_up">Monter</string>
260+
<string name="editor_move_down">Descendre</string>
259261
</resources>

0 commit comments

Comments
 (0)