Skip to content

Commit 9f5b4e0

Browse files
committed
Merge branch 'feat/physical-button-and-contacts'
2 parents 63780c1 + d6ba0f8 commit 9f5b4e0

41 files changed

Lines changed: 2472 additions & 510 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
# AGENTS.md — MG4Tasker
22

3-
Companion app to [MG4Control](../MG4Control). Rule-based automation for the SAIC MG4:
4-
at ignition, evaluate rules and apply vehicle settings — **through MG4Control**, never
5-
directly.
3+
Rule-based automation for the SAIC MG4 and part of **MG4Suite**. At ignition it evaluates
4+
rules and applies supported actions directly through the shared MG4Hardware safety layer.
5+
MG4Control is optional and is used only for the "apply saved profile" action.
6+
7+
The workspace `AGENTS.md` and normative workspace `DESIGN.md` apply; this file contains
8+
only automation-specific additions.
69

710
Commit author: malys.training@gmail.com
811

912
## The one rule that shapes everything
1013

11-
**MG4Tasker never touches the vehicle.** It reads a snapshot from MG4Control and requests
12-
named actions; MG4Control performs every write and applies the 0 km/h speed gate. So:
14+
**Automation never bypasses MG4Hardware.** MG4Tasker reads a snapshot and executes named,
15+
typed catalogue actions through MG4Hardware; it never accepts or sends a raw property ID.
16+
The low-level safety gate applies regardless of which app invokes the library. So:
1317

14-
- No `sharedUserId`, no `android.car.*` permission. MG4Tasker is an ordinary app.
15-
- One process writes to the car → no concurrent writes, one place enforces the gate.
16-
- The IPC surface is 4 methods (`ITaskerBridge`), and none takes a raw property id.
18+
- Vehicle access code, firmware routing and property/transaction IDs live in MG4Hardware,
19+
not in this app.
20+
- Rule actions are serialized; no two automation writes may interleave.
21+
- MG4Control IPC is limited to profile discovery/application and takes no raw property ID.
1722

18-
## Boundary = the signature permission
23+
## MG4Control profile boundary
1924

20-
`com.mg4.control.permission.TASKER_BRIDGE` is declared by MG4Control at
21-
`protectionLevel="signature"`. Both apps must be signed with the **same platform key** or
22-
MG4Tasker installs and does nothing. The Diagnostic tab reports the bridge state.
25+
The MG4Control profile bridge is protected by a signature permission. Both apps must be
26+
signed with the same key for profile discovery/application; all other Tasker actions work
27+
without MG4Control. The Diagnostic tab reports profile-bridge and hardware-layer state
28+
separately.
2329

2430
## Unreadable ≠ false
2531

@@ -31,8 +37,8 @@ Covered by `ConditionEvaluatorTest` and `RuleEngineTest`.
3137
## Firmware compatibility is annotation-driven
3238

3339
Every vehicle `ConditionType` / `ActionType` entry carries `@SupportedOn(...)`, derived
34-
from MG4Control's `FirmwareInfo` (`hasHeatFeatures`, `isVsmBased`, `isNewGenVsm`) and the
35-
per-generation routing in `MG4Hardware`. It is the single source of truth for:
40+
from MG4Hardware's `FirmwareInfo` and its per-generation routing. It is the single source
41+
of truth for:
3642

3743
- `docs/firmware-matrix.md` — **generated** by `FirmwareMatrix`, refreshed by the test
3844
run. Never hand-edit it.
@@ -63,18 +69,12 @@ constructor, so a Kotlin default never applies to a key absent from stored JSON;
6369
enum would be null at runtime for every pre-existing rule. Read it through `firesOn`, export
6470
the raw field. Same trap as the TypeToken bug — see `ShrinkerSafeGsonTest`.
6571

66-
## The standstill gate is configurable, and still fails closed
67-
68-
`VehicleWriteGate.allowUpToKmh` moves the "moving" boundary from 0 up to 50 km/h; the app
69-
stores the user's choice and pushes it in wherever the vehicle layer is initialised. Two
70-
invariants are not negotiable and are unit-tested: an unreadable or negative speed refuses at
71-
any threshold, and the shared layer clamps the value. Default is 0.
72+
## The standstill gate is fixed and fails closed
7273

73-
Park is the **only** thing that can rescue a refusal, and only the unreadable-speed one
74-
(`decide(speed, threshold, parked)`). It must never override a speed that did read: park at
75-
30 km/h means a signal is wrong, and trusting the gear there would unlock writes at speed.
76-
`decideNow()` is the live form and reads the gear only after the speed came back unreadable —
77-
one property read on the normal path.
74+
Vehicle-setting writes are allowed only when a valid speed read is exactly 0 km/h. Unknown,
75+
negative or moving speed refuses the action; park state does not rescue an unreadable speed.
76+
Do not expose a user-configurable moving threshold. Any existing `allowUpToKmh` or park-rescue
77+
path is safety debt to remove, not a pattern to extend.
7878

7979
## A gate refusal is "not now", so it is kept
8080

@@ -85,12 +85,12 @@ applied, one attempt each, and a history entry per drain. The poller is a single
8585
exists only while the queue does — `stopIfEmpty()` clears the flag under the lock `offer`
8686
takes, otherwise an entry can be stranded with no poller running.
8787

88-
## Vehicle writes go in MG4Control, reads too
88+
## Vehicle primitives go in MG4Hardware
8989

90-
MG4Tasker adds no vehicle access code. When a new action needs a new capability, implement
91-
the read/write in MG4Control's `MG4Hardware` (branched per firmware generation — never
92-
assume universal), expose it through `TaskerBridgeService`, then add the catalogue entry
93-
here. Property ids and per-generation routing live in MG4Control; do not duplicate them.
90+
MG4Tasker adds no low-level vehicle access code. When a new action needs a capability,
91+
implement it in the standalone MG4Hardware repository (branched per firmware generation),
92+
update the typed catalogue and tests, then update this app's submodule and executor.
93+
Property IDs and per-generation routing never live in MG4Tasker or MG4Control app code.
9494

9595
Climate, charging, radio and hands-free calls do **not** go through property ids. They use
9696
the SAIC vendor binder services the car's own apps use (`com.mg4.hardware.saic`). Keep every

‎CHANGELOG.md‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,47 @@ All notable changes to this project are documented here. Format follows
88

99
### Added
1010

11+
- **"else if" and "else" cases in a rule.** Cases are tried in the order written and exactly
12+
one runs: the first whose conditions hold, or the "else" when none did. An unreadable
13+
condition stops the rule where it stands instead of falling through to the next case or to
14+
the "else" — unreadable is not false, and falling through would write to the vehicle
15+
because a reading was missing. Each case is edited in its own window, conditions on one
16+
side and actions on the other; the rule screen shows one card per case, in evaluation
17+
order, with arrows to reorder the "else if" ones. Up to four "else if" cases per rule. The
18+
history names the case that ran, and the wait budget compares the longest case rather than
19+
the sum. A rule that names a physical button must name one in every case and takes no
20+
"else": the button decides which event stream feeds the whole rule, so a case without one
21+
would run on presses it never mentioned. Rules files gain `elseIf` / `elseActions` at format
22+
version 2; a file with no case still claims version 1 and stays readable by earlier builds,
23+
and a file whose content and version disagree is refused.
24+
1125
- **Wait** action (1 to 60 seconds), and reordering of a rule's actions with the arrows
1226
on each row. Actions run one after the other in the order shown, so a wait placed
1327
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.
28+
(climate on, then fan level). A rule made only of waits is refused at save time, and one
29+
cycle may wait two minutes in total across all its rules — beyond that the wait is cut
30+
short and reported in the history, rather than applying later actions against a stale
31+
snapshot.
1532
- Configuration tab. The speed threshold, the language and the rules file
1633
(export / import) moved there from the rules pane; the Rules tab is now the rule
1734
list and nothing else.
1835

1936
### Changed
2037

38+
- **One "Call" action** instead of two. The same entry now takes a typed number or a name
39+
from the phone book; the field is both a search and a number entry. Rules saved with the
40+
old "Call a contact" action still load and still call — the entry is deprecated, not
41+
removed, because a deleted enum name deserialises to null.
42+
- **A vehicle action is only attempted on a firmware generation its catalogue entry names.**
43+
An unidentified generation reports `firmware support not confirmed` instead of writing to a
44+
car nobody could recognise. The editor still offers everything, since hiding entries on a
45+
guess would be worse; the Diagnostic tab runs the same check and reports the same verdict.
46+
- The first launch after an upgrade opens on the Diagnostic tab: a new build's verdicts about
47+
this car are what changed.
48+
- The Diagnostic tab no longer claims "show a message" always reaches the driver: it reports
49+
the notification channel being off, which is what the executor does. Radio tuning now
50+
checks the vendor radio service, and webhooks and waits stop being reported as blocked by
51+
a vehicle layer they never touch.
2152
- Dialog text raised to the suite's sizes (20sp body, 26sp title) instead of
2253
Material's phone defaults — the test result, the language list and every
2354
confirmation. The "Remove" button of a condition or an action and the weekday chips

‎MG4Hardware‎

‎README.md‎

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ profile* — becomes available.
3333
- [MG4Control (optional)](#mg4control-optional)
3434
- [The speed gate](#the-speed-gate)
3535
- [Conditions and actions](#conditions-and-actions)
36+
- [Cases: if, else if, else](#cases-if-else-if-else)
3637
- [Firmware compatibility](#firmware-compatibility)
3738
- [Vehicle services: climate, charging, radio, calls](#vehicle-services-climate-charging-radio-calls)
3839
- [Diagnostic](#diagnostic)
@@ -192,6 +193,13 @@ switching the climate on, then setting the fan level — the **Wait** action (1
192193
holds the sequence in between. It touches nothing in the car, and appears in the history at
193194
its place in the sequence. A rule made only of waits is refused at save time: it would hold
194195
the cycle and change nothing.
196+
197+
One cycle may spend **two minutes waiting in total**, every wait of every rule it evaluates
198+
counted together — the rules of a cycle run one after the other, so a rule that waits also
199+
delays the ones after it. Past that budget the wait is cut short and the history says so:
200+
what follows would otherwise be applied against a snapshot taken before the driver left the
201+
car. A single rule whose waits exceed the budget is refused at save time, so the limit is
202+
learned in the editor rather than after the drive.
195203
- **15 minutes, and never across an ignition cycle.** A rule that fired for this drive
196204
belongs to this drive. At switch-off the queue is dropped, not applied: the car is stopped
197205
then, which is exactly what makes it the wrong moment to change the setting the next
@@ -241,6 +249,48 @@ below.
241249

242250
---
243251

252+
## Cases: if, else if, else
253+
A rule can carry several cases. They are tried in the order they are written and **exactly
254+
one of them runs**: the first whose conditions hold wins, and the "else" is what happens when
255+
none did.
256+
257+
```
258+
IF outside temperature < 5 °C → steering heating 2, seat heating 2
259+
ELSE IF outside temperature < 18 °C → fan level 1
260+
ELSE → media volume 12
261+
```
262+
263+
Without cases, that is three rules whose conditions have to exclude each other by hand, and
264+
the day one range is edited the three stop agreeing. The "else" is optional: a rule that says
265+
nothing about the remaining situations simply does nothing in them.
266+
267+
**An unreadable reading stops the rule where it stands.** It does not fall through to the next
268+
case, and it does not reach the "else". Unreadable is not false — falling through would mean
269+
writing to the vehicle *because* a value was missing, which is the one thing the engine never
270+
does. The history reports the rule as not evaluable and names the missing signal, exactly as
271+
it does for a rule with a single case.
272+
273+
**Each case is edited in its own window**, opened from its card on the rule screen: what is
274+
checked on the left, what is done on the right, each list with its own scroll and its own
275+
"add" button. The rule screen itself keeps the shape of the rule — the cases in the order they
276+
are tried, each with the size of what it checks and of what it does — and the order of the
277+
"else if" cases is changed with the arrows on their cards. A rule takes at most four "else if"
278+
cases: past that the chain stops being readable at a glance on a screen at arm's length.
279+
280+
The history names the case that ran ("applied — ELSE IF 1"), because "applied" alone leaves
281+
the one question a branched rule raises after the fact unanswered.
282+
283+
**A rule that names a physical button must name one in every case, and takes no "else".** A
284+
button condition does not only test something — it decides which event stream brings the rule
285+
its cycles, for the whole rule. A case that named no button would be evaluated on presses of
286+
buttons it never mentioned, and an "else", which tests nothing at all, would run on every
287+
press of every button. Refused at save and at import rather than silently rewired.
288+
289+
The wait budget compares the **longest** case, not the sum of them: only one case runs, so
290+
adding them up would refuse a rule that can never wait that long.
291+
292+
---
293+
244294
## Firmware compatibility
245295
Every vehicle catalogue entry carries a `@SupportedOn(...)` annotation naming the firmware
246296
generations it works on, derived from MG4Hardware's `FirmwareInfo` and per-generation
@@ -258,6 +308,14 @@ routing. That annotation is the single source of truth for three things:
258308
> Diagnostic tab is the source of truth for your own car: it reads each signal and shows
259309
> "unreadable" where the firmware does not expose it.
260310
311+
**When the generation cannot be identified, the editor still offers everything and execution
312+
refuses.** Hiding entries on a guess would leave a car unable to write rules it can perfectly
313+
well run, so the picker filters nothing without a positive match. A vehicle action, on the
314+
other hand, is only attempted on a generation its annotation actually names: anything else is
315+
reported as `firmware support not confirmed` in the history rather than written to a car we
316+
could not identify. The Diagnostic tab reports the same verdict, because it runs the same
317+
check.
318+
261319
Highlights (see the generated matrix for the full grid):
262320

263321
- **Seat / steering heating** — SWI133, SWI68, SWI165 only (the SWI69/131 trims lack the
@@ -331,6 +389,10 @@ The one step it cannot take is the write itself — applying a drive mode to see
331389
sticks would change the car under the driver. So for vehicle writes, OK means "everything
332390
checked before writing passes", and the history reports what the write did afterwards.
333391

392+
**The first launch after an upgrade opens on this tab**, because a new build's verdicts about
393+
this car are what changed. The marker is written by the main screen, never by a background
394+
receiver: a package replacement must not put an activity in front of a driver who is moving.
395+
334396
Three sections:
335397

336398
- **Execution context** — the prerequisites that decide whether rules run at all: vehicle
@@ -475,8 +537,8 @@ against the path the user picked:
475537
|---|---|
476538
| written by a newer version | `version` above the one this build understands |
477539
| unknown condition or action | a catalogue entry this build cannot perform — never silently dropped |
478-
| not a readable rules file | malformed, blank name, no condition, no action, duplicate ids, non-finite threshold, weekday outside 1–7 |
479-
| more than 20 rules | above `RuleStore.MAX_RULES` |
540+
| not a readable rules file | malformed, blank name, no condition, no action, duplicate ids, non-finite threshold, weekday outside 1–7, a case with no condition or no action, a button rule whose cases do not all name a button, cases in a file claiming version 1 |
541+
| more than 20 rules | above `RuleStore.MAX_RULES`, or a rule with more than four "else if" cases |
480542
| no rules | a valid envelope with an empty list |
481543

482544
Picking JSON that belongs to another app reports "not a readable rules file". Files over 256 KB
@@ -487,17 +549,30 @@ are refused on their length, before anything is read into memory.
487549
```json
488550
{
489551
"format": "mg4tasker-rules",
490-
"version": 1,
552+
"version": 2,
491553
"rules": [
492554
{
493555
"id": "1f2e…", "name": "Cold morning", "enabled": true, "match": "ALL",
494556
"conditions": [{ "type": "OUTSIDE_TEMP", "op": "LT", "number": 5.0 }],
495-
"actions": [{ "type": "SET_STEERING_HEAT", "number": 2 }]
557+
"actions": [{ "type": "SET_STEERING_HEAT", "number": 2 }],
558+
"elseIf": [
559+
{
560+
"match": "ALL",
561+
"conditions": [{ "type": "OUTSIDE_TEMP", "op": "LT", "number": 18.0 }],
562+
"actions": [{ "type": "SET_FAN_LEVEL", "number": 1 }]
563+
}
564+
],
565+
"elseActions": [{ "type": "SET_MEDIA_VOLUME", "number": 12 }]
496566
}
497567
]
498568
}
499569
```
500570

571+
`elseIf` and `elseActions` arrived with version 2 and are **absent** from a rule that has no
572+
other case — so a file whose rules are all plain if/then still says `"version": 1` and stays
573+
readable by a build that predates cases. A file that does carry one says 2, and an older build
574+
refuses it whole rather than applying its first case alone.
575+
501576
Renaming a `ConditionType` / `ActionType` constant therefore invalidates older files — the
502577
import reports the unknown entry rather than guessing.
503578

‎app/src/main/AndroidManifest.xml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@
106106
android:screenOrientation="landscape"
107107
android:windowSoftInputMode="adjustResize" />
108108

109+
<!-- One case of a rule ("if", "else if", "else"), edited on its own screen. -->
110+
<activity
111+
android:name=".ui.BranchEditorActivity"
112+
android:exported="false"
113+
android:screenOrientation="landscape"
114+
android:windowSoftInputMode="adjustResize" />
115+
109116
<!-- Persistent vehicle service: inits MG4Hardware, listens for ignition, evaluates
110117
rules and applies actions directly. connectedDevice matches what it does. -->
111118
<service

0 commit comments

Comments
 (0)