App 2.3.0: Server tab, live telemetry to the car server

- New Server tab: server URL, ingest token and installation id, with
  Save, Test connection (health, then a read-only token check) and the
  config-file import that upstream removed (app folder / USB stick first,
  document picker as fallback). Settings are kept encrypted and read on
  every use, so the service picks up a change without a restart. Each
  installation generates its own id; the inherited upstream UUID and
  the baked-in CONSUMPTION_API_* build fields are gone.
- Live telemetry restored from before ea8032a (TelemetryPayload,
  UploadCadence, UploadSettings, GPS) and sent in batches to
  POST /v1/telemetry instead of ABRP; the server forwards to ABRP. Adds
  ext_temp, voltage and current. Unsent samples queue in memory (about
  8 h) while offline.
- Upload tab: the live-telemetry switch and cadence controls work again;
  the dead service and autostart switches are removed.
- Manifest: location permissions and foreground service types.
- Unit tests compile again: 114 (stable) / 136 (unstable) pass. Tests
  for the deleted AbrpApi and HeadUnitWeather removed; new tests for
  ServerConfig and TelemetryQueue. Lint passes.

Not yet tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 14:23:04 +02:00
co-authored by Claude Opus 5.5
parent c253876ed2
commit 6145736c97
27 changed files with 1590 additions and 287 deletions
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Arrow into a tray: the Server tab (where the telemetry goes). Tinted by
@color/nav_tab_foreground. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M4,14V19H20V14" />
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000"
android:pathData="M12,3V13M8,7L12,3L16,7" />
</vector>
@@ -10,6 +10,7 @@
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabServer" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_server" app:icon="@drawable/ic_tab_server" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi" />
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log" />
</LinearLayout>
@@ -3,12 +3,9 @@
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="24dp" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/status_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/service_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/autostart_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_autostart" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_autostart" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_timing" android:textAppearance="@style/Text.EV.Eyebrow" />
@@ -17,6 +17,7 @@
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_service"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabService" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_service" app:icon="@drawable/ic_tab_service"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabServer" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_server" app:icon="@drawable/ic_tab_server"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabWifi" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_wifi" app:icon="@drawable/ic_tab_wifi"/>
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log"/>
</LinearLayout>
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/server_card_title" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.textfield.TextInputLayout android:id="@+id/server_url_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_server_url" app:helperText="@string/helper_server_url">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/server_url_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textUri" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/server_token_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_server_token" app:endIconMode="password_toggle" app:endIconMinSize="72dp" app:helperText="@string/helper_server_token">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/server_token_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="textPassword" android:singleLine="true" android:textSize="20sp" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout android:id="@+id/installation_id_layout" style="@style/Widget.EV.Input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="18dp" android:hint="@string/hint_installation_id" app:helperText="@string/helper_installation_id">
<com.google.android.material.textfield.TextInputEditText android:id="@+id/installation_id_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="text" android:singleLine="true" android:textSize="18sp" android:fontFamily="monospace" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton android:id="@+id/server_save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
<com.google.android.material.button.MaterialButton android:id="@+id/server_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
<LinearLayout android:id="@+id/connection_status_row" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:gravity="center_vertical" android:orientation="horizontal" android:visibility="gone">
<View android:id="@+id/connection_indicator" android:layout_width="10dp" android:layout_height="10dp" android:layout_marginEnd="12dp" android:background="@drawable/circle_indicator" />
<TextView android:id="@+id/connection_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Body" />
</LinearLayout>
</LinearLayout>
<Space android:layout_width="1dp" android:layout_height="20dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_title" android:textAppearance="@style/Text.EV.Title" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_private_body" android:textAppearance="@style/Text.EV.Secondary" android:layout_marginTop="16dp" android:lineSpacingMultiplier="1.3" />
<com.google.android.material.button.MaterialButton android:id="@+id/import_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_import" android:layout_marginTop="24dp" />
<TextView android:id="@+id/import_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Secondary" android:textIsSelectable="true" />
</LinearLayout>
</LinearLayout>
</ScrollView>
+1 -4
View File
@@ -3,12 +3,9 @@
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_service_controls" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/status_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/service_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:id="@+id/status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/bg_input_panel" android:padding="16dp" android:layout_marginTop="20dp" android:layout_marginBottom="12dp" android:text="@string/state_stopped" android:textAppearance="@style/Text.EV.Body" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/abrp_upload_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_abrp_upload_enabled" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/autostart_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_autostart" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_autostart" android:textAppearance="@style/Text.EV.Secondary" />
</LinearLayout>
<Space android:layout_width="1dp" android:layout_height="20dp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
+24 -24
View File
@@ -2,7 +2,6 @@
<string name="app_name">Nils\' MG4</string>
<!-- Main screen -->
<string name="tab_abrp">ABRP</string>
<string name="tab_service">Upload</string>
<string name="tab_vehicle">Overview</string>
<string name="tab_consumption">Consumption</string>
@@ -11,6 +10,7 @@
<string name="consumption_subtitle">Since start, motor, lifetime and trip counters</string>
<string name="charging_title">Charging</string>
<string name="charging_subtitle">Live battery values and SOC/power curve</string>
<string name="tab_server">Server</string>
<string name="tab_wifi">Wi-Fi</string>
<string name="tab_log">Logs</string>
@@ -42,45 +42,45 @@
<string name="vehicle_tire_fr">Front Right</string>
<string name="vehicle_tire_rl">Rear Left</string>
<string name="vehicle_tire_rr">Rear Right</string>
<string name="card_config">Configuration</string>
<string name="hint_api_key">ABRP API Key</string>
<string name="default_abrp_api_key" translatable="false">8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d</string>
<string name="helper_api_key">Default: open-source ABRP telemetry key. You may replace it with your own.</string>
<string name="api_key_required">Enter your ABRP API key first</string>
<string name="hint_token">ABRP User Token</string>
<string name="helper_token">Find your token in the ABRP app: Settings → Connections → Generic (Manual Entry)</string>
<string name="desc_toggle_api_key">Show or hide API key</string>
<string name="desc_toggle_token">Show or hide user token</string>
<string name="btn_save">Save</string>
<string name="btn_test">Test connection</string>
<string name="btn_import">Import from file</string>
<!-- Server page -->
<string name="server_card_title">Car server</string>
<string name="hint_server_url">Server URL</string>
<string name="helper_server_url">For example https://car.stinnesbeck.com</string>
<string name="hint_server_token">Ingest token</string>
<string name="helper_server_token">The server\'s INGEST_TOKEN, at least 24 characters</string>
<string name="hint_installation_id">Installation ID</string>
<string name="helper_installation_id">Your data is stored under this ID. Keep it when reinstalling.</string>
<string name="server_err_url">Enter an https:// URL</string>
<string name="server_err_token">The token must be at least 24 characters, without spaces</string>
<string name="server_err_installation_id">Not a valid ID (UUID)</string>
<string name="server_saved">Server settings saved</string>
<string name="server_not_configured">Server not configured — open the Server tab</string>
<string name="import_hint">Put a text file with server_url and token in %1$s (on the head unit or a USB stick in the same folder), then press Import.</string>
<!-- Config file import -->
<string name="import_ok">Config imported — press Test to verify</string>
<string name="import_ok">Config imported and saved — press Test to verify</string>
<string name="import_err_read">Could not read that file</string>
<string name="import_err_empty">No ABRP settings found in that file</string>
<string name="import_err_empty">No settings found in that file</string>
<string name="import_err_not_found">No config file found. Copy it to %1$s</string>
<!-- Connection test -->
<string name="conn_testing">Testing…</string>
<string name="conn_ok">Connected — API responding OK</string>
<string name="conn_err_auth">Auth failed — check your API key and token</string>
<string name="conn_ok">Connected — server accepts the token</string>
<string name="conn_err_auth">Token rejected — check it against the server\'s INGEST_TOKEN</string>
<string name="perm_denied_location">Location denied — telemetry will have no position. Grant it in Settings to send GPS to ABRP.</string>
<string name="perm_denied_car">Vehicle data denied — no battery %%, speed or range will be sent. Grant it in Settings.</string>
<string name="perm_denied_all">Location and vehicle data denied — only a timestamp will be sent. Grant both in Settings.</string>
<string name="conn_err_no_internet">No internet connection</string>
<string name="conn_err_timeout">Connection timed out</string>
<string name="conn_err_http">Server error (HTTP %1$d)</string>
<string name="token_required">Enter your ABRP token first</string>
<string name="card_service">Upload Service</string>
<string name="label_upload_enabled">Enable background telemetry</string>
<string name="label_abrp_upload_enabled">Upload telemetry to ABRP</string>
<string name="hint_abrp_upload_enabled">Turn this off when DiPlay sends ABRP data. Local consumption tracking and background telemetry keep running.</string>
<string name="label_autostart">Start automatically with the car</string>
<string name="hint_autostart">Uploading resumes on its own when the head unit powers up. Turn this off to start it by hand from this screen.</string>
<string name="label_abrp_upload_enabled">Send live telemetry to the server</string>
<string name="hint_abrp_upload_enabled">The server stores it and forwards it to ABRP. Turn this off when another app feeds ABRP; consumption and charging tracking keep running.</string>
<string name="status_stopped">Service not running</string>
<!-- Cadence configuration -->
<string name="label_frequency">Upload frequency</string>
@@ -100,8 +100,8 @@
<string name="state_stopped">Stopped</string>
<string name="state_starting">Starting…</string>
<string name="state_running">Running</string>
<string name="state_tracking_locally">Tracking locally — ABRP upload off</string>
<string name="notif_tracking_locally">Tracking locally — ABRP upload off</string>
<string name="state_tracking_locally">Tracking locally — live telemetry off</string>
<string name="notif_tracking_locally">Tracking locally — live telemetry off</string>
<string name="state_error">In error — last %1$d uploads failed</string>
<!-- Call log -->
+4 -4
View File
@@ -7,8 +7,8 @@
<string name="design_brand_footer">ON THE ROAD, ELECTRIC</string>
<string name="design_overview_title">Your drive at a glance.</string>
<string name="design_overview_subtitle">Data from your car, all in one place.</string>
<string name="design_abrp_title">Connect to your route.</string>
<string name="design_abrp_subtitle">Link your car with your ABRP account.</string>
<string name="design_server_title">Where your data goes.</string>
<string name="design_server_subtitle">Your own server stores it and passes it on to ABRP.</string>
<string name="design_service_title">You focus on the road.</string>
<string name="design_service_subtitle">Your data stays up to date in the background.</string>
<string name="design_wifi_title">Stay connected.</string>
@@ -18,9 +18,9 @@
<string name="design_battery">BATTERY</string>
<string name="design_energy">ENERGY AND TEMPERATURE</string>
<string name="design_tires">TIRE PRESSURES</string>
<string name="design_account">ACCOUNT LINK</string>
<string name="design_account">SERVER LINK</string>
<string name="design_private_title">Your private connection.</string>
<string name="design_private_body">The token links your car to your ABRP account. Your credentials are stored encrypted on this device.</string>
<string name="design_private_body">The token lets the car write to your server. It is stored encrypted on this device; your ABRP credentials stay on the server.</string>
<string name="design_no_data">Values that cannot be read are shown as &#x2014;.</string>
<string name="design_service_controls">UPLOAD CONTROL</string>
<string name="design_service_timing">UPDATE FREQUENCY</string>