Compare commits
89
Commits
8f49871e6f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
461f9d5578 | ||
|
|
f0f4f57fee | ||
|
|
c6e6b3e88e | ||
|
|
3d2e63ad22 | ||
|
|
6c89bf587c | ||
|
|
bae56bec2f | ||
|
|
31ea4e7d78 | ||
|
|
496fb821c3 | ||
|
|
dfd8569cfa | ||
|
|
f54e684ae1 | ||
|
|
253c768781 | ||
|
|
69d284f81b | ||
|
|
0fd024cc2e | ||
|
|
5a82fe3dc1 | ||
|
|
5ead905e5f | ||
|
|
224840bde2 | ||
|
|
d4bb502026 | ||
|
|
851a4a34d4 | ||
|
|
8d3eb80f66 | ||
|
|
79bcfa05f6 | ||
|
|
f614ab08be | ||
|
|
98ec3f3fcf | ||
|
|
bfa33d51bd | ||
|
|
c2c0640672 | ||
|
|
a293a0fa94 | ||
|
|
c71fa4504e | ||
|
|
9226616261 | ||
|
|
e5549dddb0 | ||
|
|
f88249427f | ||
|
|
f63f48131e | ||
|
|
29064cf8db | ||
|
|
5e2e354bdd | ||
|
|
727da51a3c | ||
|
|
010a0cd50d | ||
|
|
9b5fcc0734 | ||
|
|
40906ec4a7 | ||
|
|
66fed48aae | ||
|
|
d711e0cfda | ||
|
|
9f184362f0 | ||
|
|
ca2f899403 | ||
|
|
087f193f8c | ||
|
|
3ae1e18a43 | ||
|
|
2a0c4c9675 | ||
|
|
a5db5604d3 | ||
|
|
9bebf56b3a | ||
|
|
4b858e3c35 | ||
|
|
1b918e3c39 | ||
|
|
8bad5fc024 | ||
|
|
3f1e196817 | ||
|
|
55710fb722 | ||
|
|
af15b15db1 | ||
|
|
fde963533d | ||
|
|
11d53e7bc7 | ||
|
|
a53ea589b2 | ||
|
|
5f6cf1e901 | ||
|
|
c5d61d1d11 | ||
|
|
582c084b35 | ||
|
|
f3de272c69 | ||
|
|
cee0eaeab3 | ||
|
|
756ae65d0b | ||
|
|
84fb3aac21 | ||
|
|
46dccee1de | ||
|
|
8a5813fa16 | ||
|
|
6f57351866 | ||
|
|
315586f94c | ||
|
|
59ee2cae3b | ||
|
|
c578a882bd | ||
|
|
d5f68e1bd9 | ||
|
|
4fe29189c0 | ||
|
|
e874578e54 | ||
|
|
5473ab1c2b | ||
|
|
51e6f6fd48 | ||
|
|
538d03361d | ||
|
|
818442db08 | ||
|
|
e39d9cdcf9 | ||
|
|
6145736c97 | ||
|
|
c253876ed2 | ||
|
|
4ea66cf424 | ||
|
|
d4fb4ba3af | ||
|
|
de2428786a | ||
|
|
10029623e3 | ||
|
|
b2a5b32b08 | ||
|
|
a707b590f7 | ||
|
|
3da3fa9f49 | ||
|
|
3af337ca89 | ||
|
|
8e7d8b8627 | ||
|
|
9d647f2ce1 | ||
|
|
bac887bfa6 | ||
|
|
864ac612da |
+5
-6
@@ -1,12 +1,7 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
@@ -39,3 +34,7 @@ mise.local.toml
|
||||
/EVHardware/build
|
||||
/EVHardware/.gradle
|
||||
/EVHardware/**/build
|
||||
|
||||
# Filled-in Kubernetes Secrets (copy of k8s/secrets/secrets.example.yaml). Never commit.
|
||||
/k8s/secrets.yaml
|
||||
/k8s/secrets/secrets.yaml
|
||||
|
||||
Generated
-3
@@ -1,3 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-11
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-18
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-9
@@ -1,9 +0,0 @@
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-17
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1 +0,0 @@
|
||||
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":348,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}]
|
||||
@@ -1 +0,0 @@
|
||||
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":345,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}]
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"devFiles": [],
|
||||
"polyfillFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
"static/development/_ssgManifest.js",
|
||||
"static/development/_clientMiddlewareManifest.js"
|
||||
],
|
||||
"rootMainFiles": [],
|
||||
"rootMainFilesTree": {},
|
||||
"pagesChunkGroupBootstrapParams": {}
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{"encryption.key":"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=","encryption.expire_at":1792761221411}
|
||||
@@ -1 +0,0 @@
|
||||
{"max_sequence_number":0,"commit_time":"2026-10-09T13:13:41.418251Z"}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"devFiles": [],
|
||||
"polyfillFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
"static/development/_ssgManifest.js",
|
||||
"static/development/_clientMiddlewareManifest.js"
|
||||
],
|
||||
"rootMainFiles": [],
|
||||
"rootMainFilesTree": {},
|
||||
"pagesChunkGroupBootstrapParams": {}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"pid":79123,"port":3001,"hostname":"localhost","appUrl":"http://localhost:3001","startedAt":1791551621304}
|
||||
@@ -1,3 +0,0 @@
|
||||
{"timestamp":"00:00:01.802","source":"Server","level":"ERROR","message":"Turbopack build encountered 1 error:\n./app\nError: Could not find the Next.js package (next/package.json)\nResolved from: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader/app\nFilesystem root used for resolution: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader\n\nPossible causes:\n - node_modules is being reorganized by a concurrent install (e.g. pnpm adding a package with a `next` peer dependency). This is transient and should clear once the install completes.\n - node_modules/next was removed, renamed, or has a broken symlink.\n - The workspace root is incorrect — see turbopack.root in the Next.js config docs for how to configure it.\n - In a monorepo, the Next.js package may only exist in a directory above the closest directory containing a package manager lockfile. The workspace root is detected by locating the nearest package manager lockfile.\n - Next.js is installed globally rather than as a project dependency. This is not supported; install it locally.\n\nNote: To ensure a hermetic build and a portable cache, files outside of the workspace root are not compiled.\n\nhttps://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory\n\n"}
|
||||
{"timestamp":"00:00:02.079","source":"Server","level":"LOG","message":""}
|
||||
{"timestamp":"00:00:02.080","source":"Server","level":"LOG","message":"✓ Generated AGENTS.md for AI agents. Set `agentRules: false` in next.config to disable."}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"version": 4,
|
||||
"routes": {},
|
||||
"dynamicRoutes": {},
|
||||
"notFoundRoutes": []
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"priority":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[],"onMatchHeaders":[]}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
|
||||
@@ -1,15 +0,0 @@
|
||||
globalThis.__BUILD_MANIFEST = {
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"devFiles": [],
|
||||
"polyfillFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
"static/development/_ssgManifest.js",
|
||||
"static/development/_clientMiddlewareManifest.js"
|
||||
],
|
||||
"rootMainFiles": [],
|
||||
"rootMainFilesTree": {},
|
||||
"pagesChunkGroupBootstrapParams": {}
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"middleware": {},
|
||||
"sortedMiddleware": [],
|
||||
"functions": {}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}"
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"app": {},
|
||||
"appUsingSizeAdjust": false,
|
||||
"pages": {},
|
||||
"pagesUsingSizeAdjust": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"previewModeId": "a508c818777951bed52c0170453ef7e3",
|
||||
"previewModeSigningKey": "591a19b72d1a845a81057e56fb15a2ced8409045c0ed580013f217b8c4a4ea61",
|
||||
"previewModeEncryptionKey": "ded0359fbc3be263ec7ff240e7fb98fc5b836958070834f5bc954f1092afa91c"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=\"\n}"
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM="
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
self.__BUILD_MANIFEST = {
|
||||
"__rewrites": {
|
||||
"afterFiles": [],
|
||||
"beforeFiles": [],
|
||||
"fallback": []
|
||||
},
|
||||
"sortedPages": []
|
||||
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
||||
@@ -1 +0,0 @@
|
||||
self.__MIDDLEWARE_MATCHERS = [];self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB()
|
||||
@@ -1 +0,0 @@
|
||||
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||
Vendored
-55
@@ -1,55 +0,0 @@
|
||||
// This file is generated automatically by Next.js
|
||||
// Do not edit this file manually
|
||||
|
||||
type AppRoutes = never
|
||||
type PageRoutes = never
|
||||
type LayoutRoutes = never
|
||||
type RedirectRoutes = never
|
||||
type RewriteRoutes = never
|
||||
type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes
|
||||
|
||||
|
||||
interface ParamMap {
|
||||
}
|
||||
|
||||
|
||||
export type ParamsOf<Route extends Routes> = ParamMap[Route]
|
||||
|
||||
interface LayoutSlotMap {
|
||||
}
|
||||
|
||||
|
||||
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap }
|
||||
|
||||
declare global {
|
||||
/**
|
||||
* Props for Next.js App Router page components
|
||||
* @example
|
||||
* ```tsx
|
||||
* export default function Page(props: PageProps<'/blog/[slug]'>) {
|
||||
* const { slug } = await props.params
|
||||
* return <div>Blog post: {slug}</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface PageProps<AppRoute extends AppRoutes> {
|
||||
params: Promise<ParamMap[AppRoute]>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for Next.js App Router layout components
|
||||
* @example
|
||||
* ```tsx
|
||||
* export default function Layout(props: LayoutProps<'/dashboard'>) {
|
||||
* return <div>{props.children}</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
|
||||
params: Promise<ParamMap[LayoutRoute]>
|
||||
children: React.ReactNode
|
||||
} & {
|
||||
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
|
||||
}
|
||||
}
|
||||
@@ -1,132 +1,129 @@
|
||||
# AGENTS.md — EVABRPUploader
|
||||
# AGENTS.md — Nils' MG4
|
||||
|
||||
Context for AI agents and new contributors working in this repository.
|
||||
|
||||
EVABRPUploader is part of **EVSuite**. The workspace `AGENTS.md` and normative
|
||||
workspace `DESIGN.md` apply; this file contains only telemetry-specific additions.
|
||||
|
||||
## App-specific visual direction (2.2.6)
|
||||
|
||||
The user explicitly requested a permanent OLED-black theme, charcoal glass-like cards,
|
||||
muted gold and cyan accents. These app resources intentionally override the shared
|
||||
DESIGN.md palette, DayNight preference and flat-surface rules. Do not overwrite them
|
||||
with the shared token generator. Use static gradients rather than continuous effects;
|
||||
keep large touch targets, readable text and null telemetry shown as a dash.
|
||||
Wide layouts start at 1000dp; narrower windows use a bottom navigation bar.
|
||||
Start here. What this repository is, how its parts fit, how to build, test and ship, and the
|
||||
rules for changing it. Details live in the documents linked below; `SPECS.md` is the journal
|
||||
(status, to-dos, a dated log of every change) and is updated with each change.
|
||||
|
||||
## What this is
|
||||
|
||||
An Android Automotive app for the **MG4 (SAIC eh32)** that reads vehicle telemetry and
|
||||
pushes it to A Better Route Planner. It runs on the car's head unit (AAOS 9, MT2712).
|
||||
|
||||
Fork of Leon Kernan's `ABRP_Uploader`. See `LICENSE.md` — the licence status is
|
||||
**unresolved** and that is a real constraint, not paperwork.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. **Telemetry is read-only.** No vehicle settings may be written. Window automation
|
||||
was removed in 2.2.5 at the user's request, including its service and door listener.
|
||||
2. **A failed read is not a zero.** Getters return `Integer`/`Float`/`Boolean` and `null`
|
||||
means "could not read". Null fields are omitted from the payload. Sending 0 for SOC
|
||||
tells ABRP the battery is empty and wrecks a live route plan.
|
||||
3. **Fail closed.** Update origin, APK signature, unknown vehicle state — refuse rather
|
||||
than assume.
|
||||
4. **Never let a credential reach a URL, a log line, or a crash report.**
|
||||
5. **AAOS 9 must stay stable.** A third-party app crashing the head unit is unacceptable.
|
||||
Every car call goes through reflection with try/catch; keep it that way.
|
||||
6. **Power matters.** The head unit is awake whenever the car is on. No wake locks, no
|
||||
alarms. The GPS subscription and the upload tick both cost power — keep them in step.
|
||||
|
||||
## Architecture
|
||||
An app for the **MG4 (SAIC eh32)** head unit plus the backend it talks to:
|
||||
|
||||
```
|
||||
AbrpUploadService foreground service; scheduler thread does all IPC and HTTP
|
||||
CarPropertyAdapter reflection bridge to android.car (not on the compile classpath)
|
||||
TelemetryPayload pure JSON building — no Android types, unit-tested
|
||||
UploadCadence when a tick actually uploads — pure, unit-tested
|
||||
UploadSettings user cadence configuration
|
||||
UploadLog last 20 attempts + derived service state — pure, unit-tested
|
||||
SecurePrefs credentials in EncryptedSharedPreferences, migrates plaintext
|
||||
AbrpApi HTTP; credentials in the POST body, never the query string
|
||||
MainActivity configuration, service state, upload log
|
||||
MG4 head unit (Android 9, AAOS, MT2712)
|
||||
app/ "Nils' MG4" ──live data, consumption, charging──▶ server/ (Go) ──▶ InfluxDB 2 ◀── Grafana
|
||||
│ │ relays app releases from the forge
|
||||
└──live data, straight, with the driver's own key──▶ ABRP (A Better Route Planner)
|
||||
```
|
||||
|
||||
**Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM;
|
||||
anything inside the service effectively cannot be tested without a car.
|
||||
- **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the
|
||||
`EVHardware/` library, shows it (Overview, Consumption, Charging), sends live data to the
|
||||
server and to ABRP, and updates itself.
|
||||
- **server/** — Go 1.26 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
|
||||
and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays
|
||||
app releases from the private forge. It does **not** talk to ABRP. See `server/README.md`.
|
||||
- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned
|
||||
dashboards, Traefik routes). See `k8s/README.md`.
|
||||
- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`).
|
||||
|
||||
## Source sets
|
||||
Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), branch `main`. Upstream fork: `fatih` → github.com/fatihdonmezdev/FatihsMG4.
|
||||
|
||||
| Set | Contents | Ships in |
|
||||
|---|---|---|
|
||||
| `main` | everything shared | all builds |
|
||||
| `stable` | `UpdateHook` no-op | stable channel |
|
||||
| `unstable` | active GitHub pre-release updater and PackageInstaller result receiver | unstable channel |
|
||||
| `debug` | `VhalProbe` — enumerates the whole VHAL | debug builds only |
|
||||
| `testUnstable` | OTA policy tests | test only |
|
||||
## Rules
|
||||
|
||||
The stable channel does not merely disable self-update — the updater is **not in the
|
||||
APK**.
|
||||
1. **Nothing touches the Kubernetes cluster.** Write and validate manifests only; the owner
|
||||
applies them. No `kubectl` against the cluster, not even read-only, unless asked.
|
||||
2. **Secrets.** Filled-in values only in `k8s/secrets/secrets.yaml` (gitignored);
|
||||
`secrets.example.yaml` holds `CHANGE-ME` placeholders only. Stage files explicitly, check the
|
||||
index for changes that are not yours (the owner works in parallel), and scan the staged diff
|
||||
for secrets before every commit. Never print a token.
|
||||
3. **Telemetry is read-only.** The app never writes a vehicle setting.
|
||||
4. **A failed read is not a zero.** Unreadable values are `null`, omitted from payloads and
|
||||
shown as "—". Sending 0 for SOC tells ABRP the battery is empty.
|
||||
5. **Fail closed.** Updates (origin, SHA-256, package, signature), credentials, unknown state.
|
||||
6. **No credential in a URL, a log line or a crash report.** Tokens go in headers or bodies.
|
||||
7. **The head unit must stay stable.** Every car call is reflection with try/catch; no
|
||||
blocking car reads on the main thread.
|
||||
8. **Power.** No wake locks or alarms; GPS pauses while parked and idle.
|
||||
9. **Honesty.** Most behaviour cannot be verified off the car. Say what was tested (unit tests,
|
||||
emulator, podman) and what was not. "Not tried on the car" is an acceptable answer.
|
||||
|
||||
## Firmware generations
|
||||
## The car (confirmed on the owner's car, firmware `SWI68-29958-1300R69`)
|
||||
|
||||
MG4 infotainment ships as SWI68 / SWI69 / SWI131 / SWI132 / SWI133 / SWI165, and they
|
||||
differ. Property IDs confirmed on one generation are not universal. Anything vehicle-facing
|
||||
must tolerate a property simply not existing.
|
||||
- Display 1920×720 px at **160 dpi**; the app window is **1778×720 px** (the car's own sidebar),
|
||||
about **1778×640 dp** for layouts. Pages must fit that without scrolling where possible.
|
||||
- Signed with the public **AOSP test platform key**; the car build runs as `android.uid.system`.
|
||||
- Steering-wheel keys arrive as the broadcast `com.saic.keyevent.hardkey.report`
|
||||
(`android.intent.extra.hardkey.keycode` / `.down` / `.longpress`); the voice key is **287**.
|
||||
- Not published by the car: battery, cabin and outside temperature, pack energy and capacity,
|
||||
paint colour (only `ro.build.mtk2712.vehicle_type = AS28`). See `SPECS.md`, `FIRMWARE.md`.
|
||||
- No third-party apps in the MG launcher: the app opens by a double press of the voice key.
|
||||
- Its internet comes from the car itself (no Wi-Fi needed).
|
||||
|
||||
**Two read paths, split by whether the signal is firmware-agnostic:**
|
||||
## The app
|
||||
|
||||
| Signal | Read path | Firmwares |
|
||||
|---|---|---|
|
||||
| Speed, outside temp, park | `EVHardware` getters (`getVehicleSpeedKmh`, `getOutsideTempCelsius`, `isVehicleInPark`) | all six — EVHardware branches per generation internally |
|
||||
| SOC, range | `CarPropertyAdapter` vendor IDs (`0x2160F404`, `0x2140F41C`) | **SWI68 only** |
|
||||
| Charge rate/port, cabin temp | `CarPropertyAdapter` standard-AAOS IDs | wherever the VHAL implements them |
|
||||
| Tab | Contents |
|
||||
|---|---|
|
||||
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info, tyre pressures; read every 0.5 s on a background thread |
|
||||
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
||||
| Live data | on/off, ABRP API key and user token, upload frequency |
|
||||
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
|
||||
| Settings | full screen, voice key, tyre unit, accent colour, battery, charging price, version, update check, install from USB, About |
|
||||
| Logs | upload log |
|
||||
|
||||
`EVHardware.init()` is called in `AbrpUploadService.onCreate()` (idempotent); it detects
|
||||
the generation and picks the right underlying API. Fields it covers therefore work on every
|
||||
supported firmware.
|
||||
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
||||
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
||||
limits), `AbrpApi`/`AbrpConfig`, `ServerHttp`/`ServerConfig`/`PairingClient`/
|
||||
`PhoneSettingsClient`, `ConsumptionTracker`/`ConsumptionCloudClient`, `ChargeSessionTracker`,
|
||||
`RangeEstimator`, `SecurePrefs`, `ConfigImport`, `SteeringKeyListener`, `MainActivity` (UI).
|
||||
Updater (car build only): `app/src/unstable/` `OtaUpdater`, `UpdateHook`, `UsbUpdate`.
|
||||
|
||||
**Reference firmware: `SWI68-29958-1300R69`.** The vendor EV IDs still in
|
||||
`CarPropertyAdapter` are supported only on SWI68 firmware. EVHardware
|
||||
has **no EV-battery abstraction for any generation**, so SOC and range cannot go through it;
|
||||
until those vendor IDs are confirmed on the other generations they stay SWI68-only, and on
|
||||
another firmware they may silently return nothing (handled safely — the field is omitted, but
|
||||
SOC/range would be missing). Re-confirm with `VhalProbe` before trusting the vendor IDs on any
|
||||
other generation.
|
||||
**Put logic in pure, unit-tested classes** (no Android types); the service and activity are
|
||||
hard to test without a car.
|
||||
|
||||
## Working here
|
||||
**UI conventions:** OLED black, glass cards, an accent the user picks from the MG4 paints —
|
||||
always `?attr/evAccent…` (`res/values/attrs_accent.xml`, overlays in `accents.xml`), never a
|
||||
hard-coded accent hex. All UI text in `strings.xml`. 72 dp touch targets. Values right-aligned
|
||||
with tabular digits. Drop-downs are Material exposed dropdown menus and dialogs go through
|
||||
`showKeepingFullScreen` — a Spinner or a plain dialog brings the car's bars back. Android 9
|
||||
crashes on gradients with theme colours: use layers.
|
||||
|
||||
```bash
|
||||
mise run check # what CI runs: permission gate + lint + unit tests
|
||||
mise run build
|
||||
mise run logs # adb logcat, filtered to this app
|
||||
**Flavours:** `stable` (no updater at all) and `unstable` (the car build: system UID, OTA and USB
|
||||
install). The car always gets `unstable`.
|
||||
|
||||
## Build, test, ship
|
||||
|
||||
```sh
|
||||
nix develop # JDK 21, Android SDK, Go
|
||||
./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug
|
||||
build-car # build/car/NilsMG4-unstable-<version>.apk + .sha256
|
||||
publish-car # pre-release car-<version> on the forge (needs a token)
|
||||
cd server && go vet ./... && go test ./... && govulncheck ./...
|
||||
```
|
||||
|
||||
Gradle needs JDK 17. If a build fails in `JdkImageTransform` complaining about `jlink`,
|
||||
a stale Gradle daemon started by VS Code's Java extension is being reused —
|
||||
`pkill -f GradleDaemon` and retry.
|
||||
- **Versions:** three-part `versionName` in `app/build.gradle`; raise `versionCode` by one with
|
||||
every build that goes to the car (a lower code is refused as a downgrade).
|
||||
- **Getting it to the car:** in-app update (Settings → Check for updates, via the server), or
|
||||
Install from USB stick (APK + `.sha256` in the stick's top folder), or the first time through
|
||||
the Bluetooth-settings file manager (`README.md`). `OTA.md` has the whole flow.
|
||||
- **Server image:** `podman build` in `server/`, push to `git.stinnesbeck.com/nils/mg4-server:<v>`,
|
||||
pin the pushed digest in `k8s/server.yaml`. Validate manifests offline with
|
||||
`kustomize build k8s | kubeconform` (see `k8s/README.md`).
|
||||
- **Emulator:** `nix run .#car-emulator` (add `-no-window` for headless) starts an Android 9 AOSP
|
||||
emulator at 1920×720, 160 dpi on port 5560 (state in `build/emulator/`). Its image shares the
|
||||
car's platform key, so the car build installs as the system user. `adb shell wm size 1778x640`
|
||||
gives the car's app window (taps then use those coordinates; screenshots stay 1920×720). It has
|
||||
no car data (every value is "—"). While the app is open, `uiautomator dump` never sees an idle
|
||||
screen; use screenshots.
|
||||
- **After each change:** a `SPECS.md` done-log entry, docs that the change affects, explicit
|
||||
staging, commit, push.
|
||||
|
||||
## Things that will bite you
|
||||
## Documents
|
||||
|
||||
- **`android.car` is not on the compile classpath.** Everything car-related is reflection.
|
||||
R8 cannot see those uses, so `proguard-rules.pro` keeps them by name.
|
||||
- **Unit tests get a stub `org.json`** from `android.jar` that returns null. The real
|
||||
implementation is added as a test dependency; do not remove it.
|
||||
- **`service_running` in preferences goes stale-true** after a force-kill. Use
|
||||
`AbrpUploadService.isRunning()`.
|
||||
- **Permission changes fail CI** until allowlisted with a justification.
|
||||
|
||||
## Honesty requirements
|
||||
|
||||
Most of this cannot be verified off a vehicle. When reporting work, state plainly what was
|
||||
tested and what was not. "Unit tests pass, not tried on the car" is the expected and
|
||||
acceptable answer. Do not imply road-testing that did not happen.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
## This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `SPECS.md` | journal: architecture, status, signals the car lacks, to-dos, dated done-log |
|
||||
| `README.md` | user-facing overview, install, setup, config file |
|
||||
| `OTA.md` | releases, the updater's checks, USB install |
|
||||
| `FIRMWARE.md` | the head unit, firmware generations, display |
|
||||
| `server/README.md`, `k8s/README.md` | API, configuration, deployment |
|
||||
| `LICENSE.md` | MIT, EVHardware's PolyForm Noncommercial, the fork chain |
|
||||
| `DESIGN.md`, `CHANGELOG.md` | upstream's design system and release history |
|
||||
|
||||
+2
-2
@@ -39,7 +39,7 @@
|
||||
|
||||
- Charging sessions are now metered on DC only (10 kW and above). An AC charge is an
|
||||
overnight affair the head unit sleeps through, so its curve, duration and energy were
|
||||
guesses; the charging screen labels one as `AC şarj (ölçülmüyor)` rather than showing
|
||||
guesses; the charging screen labels one as `AC şarj (ölçülmüyor)` ("AC charging (not metered)") rather than showing
|
||||
the previous DC session's figures next to it.
|
||||
|
||||
### Fixed
|
||||
@@ -246,7 +246,7 @@ All notable changes to this project are documented here. Format follows
|
||||
- OTA installs now go through the platform `PackageInstaller` instead of shelling out to
|
||||
`pm install -r`. The old path handed the package manager service a file inside the app's
|
||||
private cache, which it cannot read from its own process, and every such failure was
|
||||
reported as `Kurulum reddedildi (imza uyuşmuyor?)` — a signature verdict on a correctly
|
||||
reported as `Kurulum reddedildi (imza uyuşmuyor?)` ("installation refused (signature mismatch?)") — a signature verdict on a correctly
|
||||
signed build. A session streams the archive from this process, so no second process has
|
||||
to reach our cache. Approach taken from merthankaraman/DriveHub_Dort, which solved the
|
||||
same problem on the same head unit.
|
||||
|
||||
+2
-3
@@ -91,6 +91,5 @@ say what the failure looked like, so the next person recognises it.
|
||||
|
||||
## Licensing
|
||||
|
||||
Read [`LICENSE.md`](LICENSE.md) first. The project's licence status is unresolved because
|
||||
the upstream fork source has none. Contributions are accepted on the understanding that
|
||||
they are offered under MIT once that is settled.
|
||||
Read [`LICENSE.md`](LICENSE.md) first. Contributions are accepted under the MIT Licence,
|
||||
except changes to `EVHardware/`, which fall under its PolyForm Noncommercial licence.
|
||||
|
||||
@@ -8,6 +8,258 @@ All notable changes to this project are documented here. Format follows
|
||||
|
||||
### Added
|
||||
|
||||
- `diag.DiagnosticInbox`: posts one diagnostic artifact (a bundle zip or a text report) to an
|
||||
HTTPS inbox the driver runs at home, as one JSON object shared by every app. No redirect
|
||||
followed; an address with credentials or a query is refused; 1 MiB cap.
|
||||
|
||||
### Changed
|
||||
|
||||
- Hard acceleration and hard braking start at 2.5 m/s², up from 1.5. On the car, ordinary town
|
||||
drives counted 7 to 16 hard stops every 10 km, and regeneration on High alone comes close to
|
||||
the old line. Trips recorded before keep the counts they were given.
|
||||
- `EnergySnapshot.climate.passengerTargetCelsius` is null when the car publishes no passenger
|
||||
setpoint, instead of a copy of the driver's. A screen that shows one zone for both seats says so.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Screen brightness rounds both ways: 10 % was written as native 25 and read back as 9 %.
|
||||
- A trip that started on the head unit's factory clock is dated from the corrected one.
|
||||
- `RuntimeInterfaceProbe` records ABSENT, not DENIED, when the hub hands over another interface:
|
||||
ICarPowerService and IVehiclePropertyService answer as IVehicleSettingService on SWI68.
|
||||
- The charge diagnostic line shows `pack_delta_kwh=unavailable` for an unwatched charge.
|
||||
- The ACC/TJA and speed-limiter getters log the value they read.
|
||||
|
||||
## [1.18.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- `EnergySnapshot`, `TripSample` and `EnergyTripSummary` carry the raw drive mode and regen
|
||||
codes (`getDriveModeRaw`, `getRegenLevelRaw`; 6 is one-pedal on SWI68). Raw, so an unknown code
|
||||
stays unknown instead of reading NORMAL or MEDIUM; `DriveMode.ofRaw` and `RegenLevel.ofRaw`
|
||||
decode exactly.
|
||||
- `EnergyTripSummary` counts driving style at the live 1 Hz rate: moving and hard time, hard
|
||||
accelerations and hard brakings as episodes. The 5 s track had smoothed short hard stops away.
|
||||
- `EcoScore`: a trip out of 100 from anticipation (hard brakings per 10 km), smoothness (hard
|
||||
share) and efficiency (charge against the driver's own expectation). Unmeasured parts drop out.
|
||||
|
||||
### Changed
|
||||
|
||||
- The eco verdict's TYPICAL zone is one residual either side of the expectation, half the
|
||||
prediction band. At the full 1.96 band almost every drive read TYPICAL.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `TripDetector` ends an automatic trip two seconds after the car reports P or a plugged cable,
|
||||
instead of after the two-minute standstill. The SWI68 head unit powers off about twelve
|
||||
seconds after P, so an automatic trip died with the process before the timer ran out.
|
||||
- `SocConsumptionFitter` falls back to two coefficients when the thermal term comes out
|
||||
negative, instead of refusing the fit. The 2026-10-16 bundle refused 87 segments for this
|
||||
reason alone.
|
||||
- `CarPropertyEvidence` no longer lists SWI68 battery power as never published: the vendor pack
|
||||
fallback reads −32,9 to +32,4 kW on a town drive. Screens now say "not validated yet" instead
|
||||
of "this vehicle never publishes battery power".
|
||||
|
||||
## [1.17.0] - 2026-10-14
|
||||
|
||||
### Changed
|
||||
|
||||
- `SET_CHARGE_LIMIT` is `writeProven = false`: SWI68 reads the limit back as `5`, a code, while
|
||||
the write sends a percent. Hidden from editors and refused by executors until one write is
|
||||
seen to set the limit on the car's screen.
|
||||
|
||||
### Added
|
||||
|
||||
- Probe-only vendor charge readings for the telemetry evidence: `BMS_CHRG_STS`, the AC and DC
|
||||
plug states and the kWh-since-plug-in counter, ids from MG4Control's SWI133 readings and
|
||||
unproven on SWI68. Recorded as `candidate.vendor*`; nothing reads them as values yet.
|
||||
- `RuntimeInterfaceProbe.componentMap()`, part of `survey()`: every service the
|
||||
`com.saicmotor.*` packages declare, with its exported flag and permission, from
|
||||
PackageManager metadata only. Nothing is bound or called; capped at 64 packages and
|
||||
40 services per package, with the cut recorded.
|
||||
- `windows.widestPercent` in the telemetry evidence, from `EnergySnapshot.widestWindowPercent`.
|
||||
- `SocConsumptionFitter.describe()` ends with the solve's outcome once both gates pass:
|
||||
`fit=ready rmse=…` or `fit=refused(<guard>)`. The existing prefix is unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`SaicCharging.chargeLimitPercent()` answers null outside 40–100 %.** SWI68 answered `5`,
|
||||
a code rather than a percent; callers now take their "limit unreadable" path. The setter
|
||||
is unchanged.
|
||||
- **The battery ledger ignores readings taken on the factory clock.** A head unit woken by a
|
||||
night charge runs on its late-2018 clock until it syncs; one such entry sorted first in the
|
||||
ledger and split the charge. `BatteryLedgerRecorder` now drops samples before 2025-01-01.
|
||||
|
||||
## [1.16.0] - 2026-10-11
|
||||
|
||||
### Added
|
||||
|
||||
- `EVHardware.probeFrontDoors()`: front-door evidence on firmwares the door feature does not
|
||||
claim. Connects and subscribes to DLOCK_DOOR_OPEN_STS whatever `hasDoorVolumeFeature()` says
|
||||
(MG4Control reads it through the change subscription on SWI68/69/131/165) and reports the
|
||||
managers, the subscriptions, the on-demand read and the last event per front door.
|
||||
`frontDoorOpenOrNull()` stays gated until a car confirms.
|
||||
- `SaicClimate.passengerTempState()`: the passenger target as the service answers it, -1
|
||||
included, for the diagnostic.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `SET_PASSENGER_TEMP` no longer claims SWI68: the passenger target reads -1 there, so nothing
|
||||
shows the write lands.
|
||||
|
||||
## [1.15.1] - 2026-10-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`PASSENGER_TEMP` and `DOORS_LOCKED` no longer claim SWI68.** Both were declared from the
|
||||
getters the SAIC services expose, never from a read on the car, and a SWI68 MG4 reported
|
||||
both `NOT_READABLE` (2026-10-11). The passenger target reads a zone a single-zone car does not
|
||||
have; the service answers -1. `getDoorLock` reads back the lock *request* property, and the
|
||||
car answered 0, which is neither of the two named values. The editor now hides both there.
|
||||
SWI165 keeps them until a car says otherwise.
|
||||
|
||||
## [1.15.0] - 2026-10-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Exterior-light conditions and standstill actions (CR-046).** Conditions `HEADLIGHTS`
|
||||
(off / on / daytime running), `HIGH_BEAM_ON` and `FOG_LIGHTS_ON`, plus gated actions
|
||||
`SET_HEADLIGHTS` (ON / AUTO), `SET_HIGH_BEAM` (OFF / AUTO) and `SET_FOG_LIGHTS`. They use the
|
||||
standard AAOS `HEADLIGHTS` / `HIGH_BEAM_LIGHTS` / `FOG_LIGHTS` `_STATE` and `_SWITCH` ids.
|
||||
Writes happen only at 0 km/h. A rule can never switch the low beams OFF, and can never force
|
||||
the high beam ON (`ExteriorLights`). **Proven on no generation yet**: `ExteriorLights.PROVEN`
|
||||
and `@SupportedOn` are empty and `writeProven` is false, so the entries stay hidden and inert
|
||||
until a capture proves a generation.
|
||||
- **Read-only vendor-surface tools (CR-044, CR-045)** for an app's unstable capture.
|
||||
`VendorSurfaceProbe.socSourcesReport()` reads every candidate source of state of charge side by
|
||||
side. `VendorSurfaceProbe.capture()` together with `VendorSurfaceRules.diff()` reads every
|
||||
`CarPropertyManager` property and every value getter of the vendor setting objects, before and
|
||||
after a setting is changed on the car's own screen, and lists what changed, naming the light
|
||||
properties. No setter is called: getter selection is pinned by `VendorSurfaceRulesTest`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`EcoDrivingMonitor.harshSharePercent` counts hard braking as well as hard acceleration.**
|
||||
The live steadiness share read only positive steps past `HARSH_ACCELERATION_MS2`, while
|
||||
EVChargePilot's trip review (`DrivingStyle`) has always counted both signs at the same threshold,
|
||||
so the live gauge read lower than the summary of the same drive — roughly half in stop-and-go.
|
||||
Thresholds are unchanged.
|
||||
- **The trip history keeps four times as many tracks.** `EnergyTripHistoryStore`'s default
|
||||
`maxBytes` goes from 512 KiB to 2 MiB. At about 145 KB per hour of driving, 512 KiB held roughly
|
||||
fourteen short trips' tracks, so each new drive evicted older tracks and the consumption model's
|
||||
segment count fell as trips were added (51 to 47 on SWI68, 2026-09-30). One long trip's
|
||||
4 096-sample track alone did not fit. Every reader parses the file off the main thread and the
|
||||
write stays atomic, so the cost is parse time and memory per read, not a new failure mode.
|
||||
|
||||
### Added
|
||||
|
||||
- **CUSTOM drive mode settings (CR-040).** `CustomDrive` reads and writes powertrain response,
|
||||
steering weight and brake-pedal feel by index (0 Eco/Comfort, 1 Normal, 2 Sport) on the VSM
|
||||
generations: `…Level` methods on SWI68/SWI165, `…Mode` methods on SWI69/SWI131/SWI132. The
|
||||
index↔code tables live here only — the pedal's Normal is `0`, the A9 powertrain uses the
|
||||
drive-mode scale. Writes are standstill-gated. SWI133 answers unavailable: its route is a raw
|
||||
property id this project has not verified. Codes beyond RI-006 are leads until a car confirms.
|
||||
- **`DrivingProfile` carries the CUSTOM settings and an optional `ProfileClimate` (CR-041).**
|
||||
All new fields are nullable and default to null, so every saved profile and backup reads as
|
||||
"not configured" and writes nothing new.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **An overnight charge the head unit slept through is a charge.** `ChargeEnergy.plugged` required
|
||||
the odometer to stand still between the two ledger entries around a rise; when the drive home
|
||||
was never recorded either, the evening entry predates it, the odometer "moved", and a night's
|
||||
fifty points were filed as regeneration and hidden from the battery page. A rise is now a charge
|
||||
when the odometer stood still, **or** the car reported a non-zero charging status, **or** it is
|
||||
at least 10 points and nobody watched the car move during it.
|
||||
- **Each charge carries its curve.** `ChargeEnergy.steps` is pack power per watched ledger step
|
||||
(one step per point of charge), with `peakPowerKw` and the outside temperature range;
|
||||
`ChargeEnergyReport.powerBySocBand()` aggregates the steps of every charge into 10-point bands,
|
||||
which is where the taper shows. Measured only; an unwatched charge has no steps. Both reach
|
||||
`describe()`.
|
||||
|
||||
- **A calm drive is measured, not waiting.** `EcoDrivingMonitor.harshSharePercent()` returns the
|
||||
share of hard acceleration once the window holds a minute of movement, below the advice
|
||||
threshold included. `steadiness()` still answers only above it; callers can now tell "not
|
||||
measured yet" from "measured, and calm".
|
||||
- **The power model trains on town driving.** `EnergyModelTrainer` fitted every 5 s sample,
|
||||
so an acceleration costing several times a cruise counted as model error and stop-and-go
|
||||
history never passed the residual gate. It now fits one point per kilometre of driving, a
|
||||
point per charge made rather than per instant, and `sampleCount` counts those kilometres
|
||||
(twelve at least). A history without 5 °C of temperature spread fits speed alone, with no
|
||||
weather term, instead of refusing.
|
||||
- **The charge model trains on short drives.** `SocConsumptionFitter` waited for 2 % of charge
|
||||
before cutting a segment; SWI68 publishes tenths of a percent, so every drive under about
|
||||
ten kilometres gave nothing. The drop needed is now two steps of the track's own gauge,
|
||||
capped at 2 %, and a stretch shorter than 2 km keeps accumulating instead of being dropped.
|
||||
- **Hard keys are decoded per broadcast.** `com.saic.keyevent.hardkey.report` carries Android
|
||||
key codes and `com.android.systemui.ACTION_HARD_KEY_EVENT` carries SAIC's own numbering, and
|
||||
the same press arrives on both. Read as one space, SystemUI's next-track (5) decoded as the
|
||||
report's phone (5), and a phone press (5 and 16) read as a double. `accept()` now takes a
|
||||
`Source`; SystemUI contributes only the cluster pad (6–10), which the report does not carry.
|
||||
A key neither table names is reported with an id of its own (`unknownKeyId`), so it can still
|
||||
be bound. `Event` carries `keyId`; `Button.id` keeps the numbers rules already store.
|
||||
|
||||
- **Eco-driving trip replay now accepts persisted sample cadence.** Live monitoring still rejects
|
||||
gaps over five seconds, while replay allows the expected delay between five-second track samples
|
||||
and the next telemetry frame. Stored trips can therefore report measured steadiness again.
|
||||
|
||||
### Added
|
||||
|
||||
- **What a drive asked of the pack at the moments it minds most (CP-090).** `DrivingStress.of`
|
||||
measures, per trip, the minutes of hard discharge (above 60 % of the trip's own peak) below
|
||||
20 % charge or below 0 °C outside, and the energy regenerated below 0 °C. The pack power sign
|
||||
is unvalidated (RI-002), so each trip orients itself from its falling charge; a trip that
|
||||
cannot, or with more than a tenth of its time undecidable, gives null rather than zero.
|
||||
`DrivingStress.monthly` sums the trips that could say, per local month. Measured quantities,
|
||||
never a wear figure.
|
||||
- **How long the rest of a charge takes (CP-089).** `ChargeDuration.estimate` sums, band by
|
||||
10-point band, the minutes per point the driver's own watched charges took: the median, and the
|
||||
fastest and slowest charge as the range. Charges at a similar outside temperature (±8 °C) are
|
||||
tried first, all charges after. A band on the path that no watched charge crossed is refused
|
||||
(`MISSING_BAND`), never extrapolated. `lastStretchMinutes` is the part above 80 %.
|
||||
`ChargeDuration.curveCheck` compares one finished band with the same band in at least three
|
||||
past charges; `slower` only when it took longer than the slowest of them.
|
||||
- **A daily battery digest (CP-087).** `BatteryDigest` copies one day of the health, calibration,
|
||||
exposure and charge analyses; `BatteryDigestStore` keeps a year of them, one per local day, with
|
||||
the same atomic write and quarantine as the ledger. `BatteryDigest.changes` reports only what
|
||||
moved past its own band from one day to the next — health beyond its uncertainty, a band that
|
||||
narrowed by at least a point (or a first estimate), a calibration verdict that changed — so a
|
||||
health figure wandering inside its band is never news.
|
||||
|
||||
- **Open windows are a consumption factor, like the climate (CP-086).** `EnergySnapshot` and
|
||||
`TripSample` carry `widestWindowPercent`, read from `SaicVehicleControl.widestWindowPercent()`;
|
||||
null where unread, never 0, and absent from older stored trips. The attribution gains
|
||||
`ResidualContext.WINDOWS_OPEN`: an interval with a window open at either end gets its own
|
||||
residual instead of joining a climate group, so the cabin share no longer absorbs the drag.
|
||||
|
||||
- **`EcoDriving`: the band, the levers and the refusals behind the eco coach.** `EcoDrivingMonitor`
|
||||
keeps a bounded window of the speed channel and answers one question — is this drive cheaper or
|
||||
dearer than what this driver's own fit expects at this mean speed and outside temperature —
|
||||
against the fit's own residual rather than a threshold invented for the purpose. It offers at
|
||||
most one lever: a cruising speed quoted from `SocConsumptionModel.predict`, or the share of
|
||||
moving time spent accelerating hard, chosen by precedence because the two have no common unit.
|
||||
Every silence carries its `UnavailableReason`: no fit, a speed outside the fitted envelope, a
|
||||
distance recorded under the superseded speed conversion, too little distance, too little charge
|
||||
drop, no outside temperature. `forReplay()` gives the same class an unbounded window, so a
|
||||
finished track is judged whole rather than on its last three minutes.
|
||||
|
||||
### Added
|
||||
|
||||
- **The charge side of the battery ledger, which nothing read.** Phase 5 measures the pack from
|
||||
its discharges alone: a rise in charge is only a boundary marker, so the energy going back in
|
||||
was being recorded and never looked at — and the two facts RI-002 has been blocked on since
|
||||
2026-09-13 live in exactly that gap. `ChargeEnergyAnalyzer` reads it, taking `BatteryExposure`'s
|
||||
sessions rather than cutting its own so the two can never disagree about what a charge is. Per
|
||||
charge: energy and mean power from the pack-pair integral, which way that integral moved
|
||||
(`ENERGY_FELL` is `EnergySnapshot.batteryPowerKw`'s declared convention holding, `ENERGY_ROSE`
|
||||
is it inverted, `CONTRADICTORY` when two charges disagree — never averaged), what the car's own
|
||||
counters did (`RESET` is what "since the last charge" predicts, and one observed reset outranks
|
||||
a charge that ended before the car clears them), and the odometer, because a long descent raises
|
||||
the charge too and only a stationary session is a plug. A charge the app slept through keeps its
|
||||
points and loses its power: the recorder earns an entry every quarter of an hour, so a longer
|
||||
silence is this app having been down, and the integral does not move while nobody is
|
||||
integrating. `describe()` puts the verdicts in a diagnostic bundle. Nothing is corrected and
|
||||
nothing new is sampled — no new property, no new transaction, no new permission (CP-073).
|
||||
|
||||
- **A probe that says when it was never able to ask.** `RuntimeInterfaceProbe.adapterClientMap()`
|
||||
swept nineteen service codes and recorded ABSENT for every one of them on a firmware where
|
||||
`sCarAdapter` is never assigned — nineteen answers to a question that never reached a binder,
|
||||
|
||||
+34
-29
@@ -5,13 +5,14 @@
|
||||
[](https://github.com/malys/EVHardware/actions/workflows/tests.yml)
|
||||
[](https://github.com/malys/EVHardware/actions/workflows/security.yml)
|
||||
[](https://github.com/malys/EVHardware/actions/workflows/publish.yml)
|
||||
[](https://github.com/malys/EVHardware/releases)
|
||||
[](https://github.com/malys/EVHardware/releases)
|
||||
[](LICENSE)
|
||||
[](https://malys.github.io/EVSuite/)
|
||||
|
||||
> ⚠️ **This library reads and writes a car's settings.** It runs inside apps installed on
|
||||
> an MG4 head unit. Read [DISCLAIMER.md](DISCLAIMER.md) before depending on it.
|
||||
> MG and MG4 are third-party marks used only to identify compatibility; this project is
|
||||
> independent and is not approved by SAIC Motor or MG Motor.
|
||||
> MG and MG4 are third-party marks used only to identify compatibility; this independent
|
||||
> project is not affiliated with or approved by SAIC Motor or MG Motor.
|
||||
|
||||
Shared vehicle-access layer for the SAIC MG4 (Android Automotive OS 9, MT2712). One
|
||||
implementation of the reflection-based hardware layer, the 0 km/h safety gate, the driving
|
||||
@@ -22,14 +23,34 @@ The design line: **the apps are thin. EVHardware provides the vehicle.** EVTaske
|
||||
engine between shared conditions and actions; EVChargePilot and EVABRPUploader consume the
|
||||
same energy snapshot instead of carrying their own property ids, units and fallback order.
|
||||
|
||||
Consumers: EVProfile (drive-profile manager; owns the signature-protected TaskerBridge), EVTasker, EVABRPUploader
|
||||
and EVChargePilot. Each app consumes EVHardware as a git submodule (`EVHardware/lib` as the
|
||||
`:evhardware` subproject). Common toolchain: **AGP 9.1.1 / Gradle 9.3.1 / compileSdk 36 / JDK 17**.
|
||||
|
||||
## Part of EVSuite
|
||||
|
||||
EVHardware is the shared vehicle library of [**EVSuite**](https://malys.github.io/EVSuite/), a family of independent,
|
||||
offline-first apps for the MG4 head unit (Android Automotive OS 9). Each app installs on its
|
||||
own — pick only what you need. User guides and install instructions:
|
||||
<https://malys.github.io/EVSuite/>.
|
||||
|
||||
Discover the rest of the suite:
|
||||
|
||||
[](https://github.com/malys/EVProfile)
|
||||
[](https://github.com/malys/EVTasker)
|
||||
[](https://github.com/malys/EVABRPUploader)
|
||||
[](https://github.com/malys/EVChargePilot)
|
||||
[](https://github.com/malys/EVLauncher)
|
||||
[](https://github.com/malys/EVSwipe)
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [Part of EVSuite](#part-of-evsuite)
|
||||
- [Overview](#overview)
|
||||
- [How it works](#how-it-works)
|
||||
- [Install](#install)
|
||||
- [The EVSuite](#the-evsuite)
|
||||
- [Building](#building)
|
||||
- [Project documents](#project-documents)
|
||||
- [Security](#security)
|
||||
@@ -114,9 +135,16 @@ permissions are `signature|privileged`); reads of standard AAOS properties work
|
||||
The library itself declares no permissions and no `sharedUserId` — that is the consuming
|
||||
app's decision.
|
||||
|
||||
---
|
||||
## Install
|
||||
|
||||
### External projects: consume the AAR
|
||||
EVHardware is a library, not an APK: it ships inside the consumer apps (EVProfile, EVTasker, EVABRPUploader,
|
||||
EVChargePilot). Those apps are sideloaded on the head unit via the keyboard route: open a
|
||||
text field, long-press `,` on the on-screen keyboard → **Language settings** → search
|
||||
`backup` then press back to reach Android Settings → enable **Developer options** +
|
||||
**Install unknown apps** → search `storage` and open the APK. See each app's README for
|
||||
the full steps.
|
||||
|
||||
### Consume the AAR
|
||||
|
||||
Projects outside this org can depend on EVHardware as a binary instead of a submodule. A
|
||||
version tag publishes the AAR to GitHub Packages (`com.evsuite:evhardware:<version>`), and
|
||||
@@ -133,29 +161,6 @@ dependencies { implementation("com.evsuite:evhardware:0.1.0-SNAPSHOT") }
|
||||
The AAR carries `consumer-rules.pro`, so a consumer's R8 keeps the reflected names. Vehicle
|
||||
writes still require the consuming app to be signed with the ROM platform key.
|
||||
|
||||
## Install
|
||||
EVHardware is a library — it ships inside the consumer apps (EVProfile, EVTasker), not
|
||||
as its own APK. Those apps are sideloaded on the head unit via the keyboard route: open a
|
||||
text field, long-press `,` on the on-screen keyboard → **Language settings** → search
|
||||
`backup` then press back to reach Android Settings → enable **Developer options** +
|
||||
**Install unknown apps** → search `storage` and open the APK. See each app's README for
|
||||
the full steps.
|
||||
|
||||
## The EVSuite
|
||||
Part of a small set of projects for the SAIC MG4 (AAOS 9, MT2712), all sharing the
|
||||
**EVHardware** vehicle layer:
|
||||
|
||||
| Project | Role |
|
||||
|---|---|
|
||||
| [EVHardware](https://github.com/malys/EVHardware) | Shared vehicle-access layer: reflection hardware layer, 0 km/h safety gate, driving models, condition/action catalogue + firmware matrix |
|
||||
| [EVProfile](https://github.com/malys/EVProfile) | Drive-profile manager; applies settings at startup; owns the signature-protected TaskerBridge |
|
||||
| [EVTasker](https://github.com/malys/EVTasker) | Rule engine — *when* conditions *then* actions — driving the car through EVProfile |
|
||||
| [EVABRPUploader](https://github.com/malys/EVABRPUploader) | Live telemetry uploader to A Better Route Planner |
|
||||
| [EVChargePilot](https://github.com/malys/EVChargePilot) | Offline live energy dashboard and local trip analyser |
|
||||
|
||||
Common toolchain: **AGP 9.1.1 / Gradle 9.3.1 / compileSdk 36 / JDK 17**. Each app consumes
|
||||
EVHardware as a git submodule (`EVHardware/lib` as the `:evhardware` subproject).
|
||||
|
||||
## Building
|
||||
```bash
|
||||
./gradlew :lib:testDebugUnitTest # unit tests + regenerates docs/firmware-matrix.md
|
||||
|
||||
@@ -24,7 +24,7 @@ Every condition a rule can test and every action it can run, straight out of `Co
|
||||
| Call in progress | `IN_CALL` | on / off | any |
|
||||
| Drive duration | `DRIVE_DURATION` | 0…600 min (=, <, >) | any |
|
||||
| Random chance | `RANDOM_CHANCE` | 1…100 % | any |
|
||||
| Doors locked | `DOORS_LOCKED` | on / off | SWI68, SWI165 |
|
||||
| Doors locked | `DOORS_LOCKED` | on / off | SWI165 |
|
||||
| Front door open | `FRONT_DOOR_OPEN` | on / off | SWI133 |
|
||||
| Data used today | `DATA_USED_TODAY` | 0…100000 MB (=, <, >) | any |
|
||||
| Data used this month | `DATA_USED_MONTH` | 0…100000 MB (=, <, >) | any |
|
||||
@@ -72,7 +72,7 @@ Every condition a rule can test and every action it can run, straight out of `Co
|
||||
| Air recirculation on | `RECIRC` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
|
||||
| Fan speed | `FAN_SPEED` | 0…11 (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
|
||||
| Set temperature | `TEMPERATURE_SET` | 17…33 °C (=, <, >) | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
|
||||
| Passenger temperature | `PASSENGER_TEMP` | 17…33 °C (=, <, >) | SWI68, SWI165 |
|
||||
| Passenger temperature | `PASSENGER_TEMP` | 17…33 °C (=, <, >) | SWI165 |
|
||||
| ECON mode on | `ECON_MODE` | on / off | SWI68, SWI165 |
|
||||
| Windscreen defroster on | `FRONT_DEFROST` | on / off | SWI68, SWI165 |
|
||||
| Rear window defroster on | `REAR_DEFROST` | on / off | SWI68, SWI165 |
|
||||
@@ -116,6 +116,14 @@ Every condition a rule can test and every action it can run, straight out of `Co
|
||||
| Speed limit tone | `SPEED_LIMIT_TONE` | on / off | SWI133, SWI132 |
|
||||
| ADAS sound warning | `SOUND_WARNING` | on / off | SWI68, SWI165, SWI69, SWI131, SWI132 |
|
||||
|
||||
### Lights
|
||||
|
||||
| Condition | Entry | Tested against | Firmware |
|
||||
|---|---|---|---|
|
||||
| Low beams | `HEADLIGHTS` | Off, On, Daytime running | none |
|
||||
| High beam on | `HIGH_BEAM_ON` | on / off | none |
|
||||
| Fog lights on | `FOG_LIGHTS_ON` | on / off | none |
|
||||
|
||||
A reading the car does not answer makes the rule **not evaluable**: it does not fire, and it does not count as false.
|
||||
|
||||
## Actions
|
||||
@@ -151,7 +159,7 @@ A reading the car does not answer makes the rule **not evaluable**: it does not
|
||||
|---|---|---|:--:|---|
|
||||
| Climate on/off | `SET_CLIMATE_POWER` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
|
||||
| Cabin temperature | `SET_CABIN_TEMP` | 17…33 °C | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
|
||||
| Passenger temperature | `SET_PASSENGER_TEMP` | 17…33 °C | — | SWI68, SWI165 |
|
||||
| Passenger temperature | `SET_PASSENGER_TEMP` | 17…33 °C | — | SWI165 |
|
||||
| Air conditioning | `SET_AC` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
|
||||
| ECON mode | `SET_ECON` | on / off | — | SWI68, SWI165 |
|
||||
| Climate AUTO | `SET_CLIMATE_AUTO` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
|
||||
@@ -170,7 +178,7 @@ A reading the car does not answer makes the rule **not evaluable**: it does not
|
||||
|
||||
| Action | Entry | Asks for | When stopped only | Firmware |
|
||||
|---|---|---|:--:|---|
|
||||
| Charge limit | `SET_CHARGE_LIMIT` | 40…100 % | — | SWI68, SWI165 |
|
||||
| Charge limit ⚠️ | `SET_CHARGE_LIMIT` | 40…100 % | — | SWI68, SWI165 |
|
||||
| Allow charging | `SET_CHARGING_ENABLED` | on / off | — | SWI68, SWI165 |
|
||||
| Scheduled charging | `SET_CHARGE_SCHEDULE` | on / off | — | SWI68, SWI165 |
|
||||
| Charging window | `SET_CHARGE_WINDOW` | a start and an end time | — | SWI68, SWI165 |
|
||||
@@ -217,6 +225,14 @@ A reading the car does not answer makes the rule **not evaluable**: it does not
|
||||
| Lane departure sound warning | `SET_LAS_WARNING_SOUND` | on / off | — | SWI132 |
|
||||
| Lane departure steering vibration | `SET_LAS_WARNING_VIBRATION` | on / off | — | SWI132 |
|
||||
|
||||
### Lights
|
||||
|
||||
| Action | Entry | Asks for | When stopped only | Firmware |
|
||||
|---|---|---|:--:|---|
|
||||
| Low beams ⚠️ | `SET_HEADLIGHTS` | On, Automatic | yes | none |
|
||||
| High beam ⚠️ | `SET_HIGH_BEAM` | Off, Automatic | yes | none |
|
||||
| Fog lights ⚠️ | `SET_FOG_LIGHTS` | on / off | yes | none |
|
||||
|
||||
### System
|
||||
|
||||
| Action | Entry | Asks for | When stopped only | Firmware |
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
| RECIRC | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| FAN_SPEED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| TEMPERATURE_SET | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| PASSENGER_TEMP | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| ECON_MODE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| FRONT_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| REAR_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
@@ -55,12 +55,15 @@
|
||||
| WINDOW_PASSENGER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| WINDOW_REAR_LEFT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| WINDOW_REAR_RIGHT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| DOORS_LOCKED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| DOORS_LOCKED | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| FRONT_DOOR_OPEN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| SEAT_HEAT_LEFT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| HEADLIGHTS | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| HIGH_BEAM_ON | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| FOG_LIGHTS_ON | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| RADIO_PLAYING | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| AEB_ENABLED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
@@ -103,9 +106,12 @@
|
||||
| SET_SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| SET_HEADLIGHTS ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| SET_HIGH_BEAM ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| SET_FOG_LIGHTS ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| SET_CLIMATE_POWER | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| SET_CABIN_TEMP | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| SET_PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_PASSENGER_TEMP | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_AC | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| SET_ECON | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_CLIMATE_AUTO | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
@@ -119,7 +125,7 @@
|
||||
| SET_WINDOW_REAR_LEFT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_WINDOW_REAR_RIGHT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_DOOR_LOCK | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_CHARGE_LIMIT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_CHARGE_LIMIT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_CHARGING_ENABLED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_CHARGE_SCHEDULE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| SET_CHARGE_WINDOW | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
@@ -143,12 +143,12 @@ object CarPropertyEvidence {
|
||||
* between them, zero readings for either signal, and `EV_INSTANTANEOUS_CHARGE_RATE` and
|
||||
* `EV_BATTERY_AVG_TEMP` answering `no CarPropertyValue` on every property probe.
|
||||
*
|
||||
* Consequence worth stating plainly: the CP-030 energy model can never train from battery
|
||||
* power on this generation, because there is no battery power to integrate. An arrival
|
||||
* forecast on this car has to come from state of charge per kilometre instead.
|
||||
* Battery power left this list once the vendor pack voltage × current fallback published:
|
||||
* −32,9 to +32,4 kW on the urban drive of 2026-10-16, net agreeing with the charge drop.
|
||||
* The standard property is still silent, but a screen asking "is there battery power"
|
||||
* now gets "not validated yet", which is the true answer, instead of "never".
|
||||
*/
|
||||
private val neverPublishedByFirmware: Map<Signal, Set<FirmwareInfo.Gen>> = mapOf(
|
||||
Signal.BATTERY_POWER_KW to setOf(FirmwareInfo.Gen.SWI68),
|
||||
Signal.BATTERY_TEMPERATURE_CELSIUS to setOf(FirmwareInfo.Gen.SWI68),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.evsuite.hardware
|
||||
|
||||
import java.lang.reflect.Method
|
||||
|
||||
/**
|
||||
* The three settings the car exposes only in [com.evsuite.hardware.model.DriveMode.CUSTOM]:
|
||||
* powertrain response, steering weight and brake-pedal feel (CR-040).
|
||||
*
|
||||
* Callers speak in an **index** — 0 Eco/Comfort, 1 Normal, 2 Sport — and never in vehicle
|
||||
* codes. The codes differ per setting and per platform, and one of them is a trap: the pedal's
|
||||
* Normal is `0`, not the middle value. The conversion lives in [code] / [index] and nowhere else.
|
||||
*
|
||||
* **Per generation.** VSM-based firmwares only, through the same VSM instance [VsmGlass] uses:
|
||||
*
|
||||
* • [Family.VSM] — SWI68 / SWI165, `VehicleSettingManager` `…Level` methods
|
||||
* • [Family.A9] — SWI69 / SWI131 / SWI132, `CarVehicleSettingClient` `…Mode` methods
|
||||
*
|
||||
* SWI133 and unknown firmware answer [isAvailable] false. Their route is a raw property id, and
|
||||
* no id for these settings is verified on this project — writing an unverified id is exactly
|
||||
* what the library refuses to do. A method name is different in kind: if it is wrong it does
|
||||
* not resolve, and the setting reports unavailable instead of writing somewhere else.
|
||||
*
|
||||
* **Evidence.** The VSM read/write codes (powertrain and steering 1/2/3, pedal 0/1/2) agree
|
||||
* with RI-006. Which pedal code is Comfort and Sport, and the A9 powertrain offset (2/3/4,
|
||||
* the drive-mode scale), are community leads (MG4Control 2.6.7, behaviour reference only) and
|
||||
* stay unproven until an on-vehicle read-after-write confirms them per generation.
|
||||
*/
|
||||
object CustomDrive {
|
||||
|
||||
enum class Setting { POWER, STEERING, PEDAL }
|
||||
|
||||
enum class Family { VSM, A9 }
|
||||
|
||||
/** Positions a caller may ask for. */
|
||||
val INDICES = 0..2
|
||||
|
||||
/** Pedal codes by index: Comfort 1, Normal **0**, Sport 2. */
|
||||
private val PEDAL_CODES = intArrayOf(1, 0, 2)
|
||||
|
||||
fun familyOf(gen: FirmwareInfo.Gen): Family? = when (gen) {
|
||||
FirmwareInfo.Gen.SWI68, FirmwareInfo.Gen.SWI165 -> Family.VSM
|
||||
FirmwareInfo.Gen.SWI69, FirmwareInfo.Gen.SWI131, FirmwareInfo.Gen.SWI132 -> Family.A9
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Vehicle code for [index], or null when [index] is not a position. */
|
||||
fun code(setting: Setting, index: Int, family: Family): Int? {
|
||||
if (index !in INDICES) return null
|
||||
return when (setting) {
|
||||
Setting.POWER -> index + if (family == Family.A9) 2 else 1
|
||||
Setting.STEERING -> index + 1
|
||||
Setting.PEDAL -> PEDAL_CODES[index]
|
||||
}
|
||||
}
|
||||
|
||||
/** Index for a code read from the car, or null when the code is no known position. */
|
||||
fun index(setting: Setting, code: Int, family: Family): Int? =
|
||||
INDICES.firstOrNull { code(setting, it, family) == code }
|
||||
|
||||
fun methodName(setting: Setting, family: Family, write: Boolean): String {
|
||||
val stem = when (family) {
|
||||
Family.VSM -> when (setting) {
|
||||
Setting.POWER -> "ElectricPowertrainLevel"
|
||||
Setting.STEERING -> "SteeringLevel"
|
||||
Setting.PEDAL -> "BrakePedalLevel"
|
||||
}
|
||||
Family.A9 -> when (setting) {
|
||||
Setting.POWER -> "DrivingPowerTrainMode"
|
||||
Setting.STEERING -> "SteeringMode"
|
||||
Setting.PEDAL -> "BrakePedalMode"
|
||||
}
|
||||
}
|
||||
return (if (write) "set" else "get") + stem
|
||||
}
|
||||
|
||||
private val INT = Int::class.javaPrimitiveType!!
|
||||
|
||||
// Resolved once per VSM class, like VsmGlass: a profile apply reads and writes all three.
|
||||
private var owner: Class<*>? = null
|
||||
private val methods = HashMap<String, Method?>()
|
||||
|
||||
@Synchronized
|
||||
private fun method(vsm: Any, name: String, vararg types: Class<*>): Method? {
|
||||
if (owner !== vsm.javaClass) {
|
||||
owner = vsm.javaClass
|
||||
methods.clear()
|
||||
}
|
||||
return methods.getOrPut(name) {
|
||||
try {
|
||||
vsm.javaClass.getMethod(name, *types)
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG, " VSM: $name absent — ${e.javaClass.simpleName}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun family(): Family? = familyOf(FirmwareInfo.getGeneration())
|
||||
|
||||
/** True when this firmware's VSM carries the setter for [setting]. */
|
||||
fun isAvailable(setting: Setting): Boolean {
|
||||
val family = family() ?: return false
|
||||
val vsm = EVHardware.vsmInstance() ?: return false
|
||||
return method(vsm, methodName(setting, family, write = true), INT) != null
|
||||
}
|
||||
|
||||
/**
|
||||
* The car's current position for [setting], or null when it cannot tell.
|
||||
*
|
||||
* Null covers an absent method, a failed call, and a code outside the scale — the last one
|
||||
* is logged raw, because it is the evidence a wrong scale would leave behind.
|
||||
*/
|
||||
fun read(setting: Setting): Int? {
|
||||
val family = family() ?: return null
|
||||
val vsm = EVHardware.vsmInstance() ?: return null
|
||||
val name = methodName(setting, family, write = false)
|
||||
val get = method(vsm, name) ?: return null
|
||||
val raw = try {
|
||||
(get.invoke(vsm) as? Number)?.toInt()
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG, " VSM: $name() exc: ${e.message}")
|
||||
null
|
||||
} ?: return null
|
||||
return index(setting, raw, family).also {
|
||||
if (it == null && raw >= 0) AppLogger.w(TAG, " $name() = $raw — outside the $family scale")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets [setting] to position [index]. The car obeys only while it is in CUSTOM; the caller
|
||||
* decides when that is (EVProfile shows the controls only then).
|
||||
*/
|
||||
@RequiresStandstill
|
||||
fun write(setting: Setting, index: Int): Boolean {
|
||||
val family = family() ?: return false
|
||||
val value = code(setting, index, family) ?: return false
|
||||
val vsm = EVHardware.vsmInstance() ?: return false
|
||||
val name = methodName(setting, family, write = true)
|
||||
val set = method(vsm, name, INT) ?: return false
|
||||
// [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable.
|
||||
if (!VehicleWriteGate.allow("VSM $name")) return false
|
||||
return try {
|
||||
set.invoke(vsm, value)
|
||||
AppLogger.i(TAG, " $name($value) ← index $index ($family)")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
AppLogger.w(TAG, " VSM: $name($value) exc: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "EV_CUSTOM"
|
||||
}
|
||||
@@ -335,18 +335,18 @@ object EVHardware {
|
||||
}
|
||||
|
||||
/**
|
||||
* État d'ignition courant, en valeurs [IgnitionState] (AAOS).
|
||||
* Current ignition state, as [IgnitionState] (AAOS) values.
|
||||
*
|
||||
* CarPropertyManager d'abord. Mais SWI68 n'expose pas IGNITION_STATE via CPM : la
|
||||
* property revient vide, et l'état d'allumage passait pour illisible alors que Katman5
|
||||
* le rapporte correctement — le log le montre (`Katman5: state initial = 2 (RUN/READY)`)
|
||||
* pendant que le diagnostic classait IGNITION_STATE en NOT_READABLE.
|
||||
* CarPropertyManager first. But SWI68 does not expose IGNITION_STATE through CPM: the
|
||||
* property comes back empty, and the ignition state looked unreadable although Katman5
|
||||
* reports it correctly — the log shows it (`Katman5: state initial = 2 (RUN/READY)`) while
|
||||
* the diagnostics classed IGNITION_STATE as NOT_READABLE.
|
||||
*
|
||||
* D'où le repli sur le dernier état livré par IVehicleConditionService, converti :
|
||||
* Katman5 parle en [CarIgnitionItem], cette fonction rend du [IgnitionState], et les
|
||||
* deux échelles ne coïncident pas (RUN vaut 2 chez l'un, OFF vaut 2 chez l'autre).
|
||||
* Hence the fallback to the last state delivered by IVehicleConditionService, converted:
|
||||
* Katman5 speaks [CarIgnitionItem], this function returns [IgnitionState], and the two
|
||||
* scales do not line up (RUN is 2 in one, OFF is 2 in the other).
|
||||
*
|
||||
* Returns -1 si rien n'est lisible, 0 si la property existe mais ne dit rien.
|
||||
* Returns -1 if nothing is readable, 0 if the property exists but says nothing.
|
||||
*/
|
||||
fun getCurrentIgnitionState(): Int {
|
||||
val v0 = getIntPropertyCPM(PROP_IGNITION_STATE, 0)
|
||||
@@ -356,7 +356,7 @@ object EVHardware {
|
||||
return vcmIgnitionAsAaos() ?: global
|
||||
}
|
||||
|
||||
/** Le dernier état Katman5 traduit en [IgnitionState], ou null s'il n'y en a pas eu. */
|
||||
/** The last Katman5 state translated into [IgnitionState], or null if there was none. */
|
||||
private fun vcmIgnitionAsAaos(): Int? = when (sLastVcmIgnitionState) {
|
||||
CarIgnitionItem.OFF -> IgnitionState.OFF
|
||||
CarIgnitionItem.ACCESSORY -> IgnitionState.ACC
|
||||
@@ -418,12 +418,15 @@ object EVHardware {
|
||||
/** Total mileage, the vendor's own integer — `PERF_ODOMETER` never publishes here. */
|
||||
private const val PROP_VENDOR_TOTAL_MILEAGE = 0x21401566
|
||||
|
||||
/**
|
||||
* Charging session state. The vehicle's own values, from the stock charging screen:
|
||||
* 1 = AC charging, 10 = DC charging, 5 = connecting, 7 = connected but not charging,
|
||||
* 8 = stopped. Anything else is not a session.
|
||||
*/
|
||||
private const val PROP_VENDOR_CHARGE_STATUS = 0x2140F409
|
||||
// Ids from MG4Control's SWI133 readings; unproven on SWI68. The plug and energy ids are
|
||||
// probe-only. PROP_BMS_CHRG_STS is also read by getVendorChargeStatus() — the charging session
|
||||
// state, the vehicle's own values from the stock charging screen: 1 = AC charging,
|
||||
// 10 = DC charging, 5 = connecting, 7 = connected but not charging, 8 = stopped. Anything
|
||||
// else is not a session.
|
||||
private const val PROP_BMS_CHRG_STS = 0x2140F409 // vendor int
|
||||
private const val PROP_CCU_ONBD_PLUG = 0x2140F43E // vendor int, AC socket
|
||||
private const val PROP_CCU_OFFBD_PLUG = 0x2140F43F // vendor int, DC socket
|
||||
private const val PROP_ENERGY_SINCE_CHARGE = 0x2160A1BB // vendor float kWh
|
||||
|
||||
/**
|
||||
* `HVAC_AMBIENT_TEMPERATURE`, the one in-car candidate left on this firmware.
|
||||
@@ -479,6 +482,24 @@ object EVHardware {
|
||||
?.takeIf { it.isFinite() && it >= 0f }
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe-only vendor charge properties, read raw. MG4Control (a system-signed app) reads them
|
||||
* on SWI133: `BMS_CHRG_STS` 1 while charging, the AC and DC plug states 0 off a charge and
|
||||
* non-zero on the active socket, and a kWh counter that resets at plug-in. Whether this app
|
||||
* may read them on SWI68, and what they say there, is what the capture is for.
|
||||
*/
|
||||
internal fun probeVendorChargeStatus(): Int? = probeVendorInt(PROP_BMS_CHRG_STS)
|
||||
internal fun probeVendorPlugAc(): Int? = probeVendorInt(PROP_CCU_ONBD_PLUG)
|
||||
internal fun probeVendorPlugDc(): Int? = probeVendorInt(PROP_CCU_OFFBD_PLUG)
|
||||
internal fun probeVendorEnergySinceChargeKwh(): Float? = supportedTelemetryRead {
|
||||
getFloatPropertyCPM(PROP_ENERGY_SINCE_CHARGE, AREA_GLOBAL)?.takeIf { it.isFinite() }
|
||||
}
|
||||
|
||||
/** Null when unreadable, never the -1 [getIntPropertyCPM] stands in for a failure with. */
|
||||
private fun probeVendorInt(propId: Int): Int? = supportedTelemetryRead {
|
||||
readPropertyCPM(propId, AREA_GLOBAL, Int::class.javaObjectType) { sCpmGetIntMethod } as? Int
|
||||
}
|
||||
|
||||
fun getStandardRangeKm(): Float? = supportedTelemetryRead {
|
||||
getFloatPropertyCPM(PROP_RANGE_REMAINING, AREA_GLOBAL)
|
||||
?.takeIf { it.isFinite() && it >= 0f }
|
||||
@@ -509,10 +530,10 @@ object EVHardware {
|
||||
*
|
||||
* The vendor property, read second: the charging service is the better source when it
|
||||
* answers, and this is what is left on a unit where it does not.
|
||||
* See [PROP_VENDOR_CHARGE_STATUS] for the values.
|
||||
* See [PROP_BMS_CHRG_STS] for the values.
|
||||
*/
|
||||
fun getVendorChargeStatus(): Int? =
|
||||
getIntPropertyCPM(PROP_VENDOR_CHARGE_STATUS, AREA_GLOBAL).takeIf { it >= 0 }
|
||||
getIntPropertyCPM(PROP_BMS_CHRG_STS, AREA_GLOBAL).takeIf { it >= 0 }
|
||||
|
||||
fun getCabinTemperatureCelsius(): Float? = supportedTelemetryRead {
|
||||
getFloatPropertyCPM(PROP_CABIN_TEMP, AREA_HVAC)
|
||||
@@ -642,6 +663,15 @@ object EVHardware {
|
||||
probeProperty("HVAC_AMBIENT_TEMPERATURE", PROP_HVAC_AMBIENT_TEMP_SWI68, AREA_GLOBAL, Float::class.javaObjectType),
|
||||
)
|
||||
|
||||
/**
|
||||
* The six exterior-light properties (CR-046), read raw whatever [ExteriorLights.PROVEN] says:
|
||||
* this is the evidence that fills it. EVTasker's Diagnostic shows and exports it — read once
|
||||
* with a light off and once with it on; a generation is proven when the state follows.
|
||||
*/
|
||||
fun probeExteriorLights(): List<PropertyReport> = ExteriorLights.NAMES.map { (id, name) ->
|
||||
probeProperty(name, id, AREA_GLOBAL, Int::class.javaObjectType)
|
||||
}
|
||||
|
||||
private fun probeProperty(name: String, propId: Int, areaId: Int, boxed: Class<*>): PropertyReport {
|
||||
val cpm = sCarPropertyManager
|
||||
?: return PropertyReport(name, propId, areaId, "CarPropertyManager not connected")
|
||||
@@ -661,6 +691,118 @@ object EVHardware {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The vendor setting objects this library already holds, by the name a report prints.
|
||||
* Probe use only ([com.evsuite.hardware.probe.VendorSurfaceProbe]); no production path
|
||||
* reads this, and the probe calls nothing on them but value getters.
|
||||
*/
|
||||
internal fun vendorSettingHandles(): Map<String, Any> = listOfNotNull(
|
||||
sVsm?.let { "VehicleSetting" to it }, // SWI68/165 manager, A9 CarVehicleSettingClient
|
||||
sVsm133?.let { "VehicleSetting133" to it },
|
||||
sVcontrol?.let { "VehicleControl" to it },
|
||||
sGeneral?.let { "General" to it },
|
||||
sCarGeneral?.let { "CarGeneral" to it },
|
||||
sCarState?.let { "CarState" to it },
|
||||
).toMap()
|
||||
|
||||
/**
|
||||
* Every property the CarPropertyManager lists, read once per area, keyed `CPM 0x…/area`.
|
||||
* Probe use only. Stops at [deadlineMs] and says so rather than blocking a diagnostic on a
|
||||
* VHAL that answers slowly.
|
||||
*/
|
||||
internal fun readAllCarProperties(deadlineMs: Long): Map<String, String> {
|
||||
val cpm = sCarPropertyManager ?: return mapOf("CPM" to "not connected")
|
||||
cacheCarPropertyMethods(cpm)
|
||||
val getProperty = sCpmGetPropertyMethod ?: return mapOf("CPM" to "no getProperty")
|
||||
val configs = try {
|
||||
cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*>
|
||||
} catch (e: Exception) {
|
||||
return mapOf("CPM" to "getPropertyList: ${e.readFailureReason()}")
|
||||
} ?: return mapOf("CPM" to "getPropertyList: null")
|
||||
val out = sortedMapOf<String, String>()
|
||||
for (config in configs) {
|
||||
if (config == null) continue
|
||||
if (System.currentTimeMillis() > deadlineMs) {
|
||||
out["CPM ~"] = "time budget spent after ${out.size} values"
|
||||
break
|
||||
}
|
||||
val type = config.javaClass
|
||||
val id = runCatching { type.getMethod("getPropertyId").invoke(config) as Int }.getOrNull() ?: continue
|
||||
val valueType = runCatching { type.getMethod("getPropertyType").invoke(config) as Class<*> }
|
||||
.getOrNull() ?: Any::class.java
|
||||
val areas = runCatching { type.getMethod("getAreaIds").invoke(config) as IntArray }
|
||||
.getOrNull()?.takeIf { it.isNotEmpty() } ?: intArrayOf(AREA_GLOBAL)
|
||||
for (area in areas) {
|
||||
val key = "CPM 0x%08X/%d".format(id, area)
|
||||
out[key] = try {
|
||||
val holder = getProperty.invoke(cpm, valueType, id, area)
|
||||
if (holder == null) "no value" else {
|
||||
val status = (holder.javaClass.getMethod("getStatus").invoke(holder) as? Int)
|
||||
?.let(CarPropertyEvidence::describe) ?: "no status"
|
||||
"$status ${com.evsuite.hardware.probe.VendorSurfaceRules.render(
|
||||
holder.javaClass.getMethod("getValue").invoke(holder))}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.readFailureReason()
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Exterior lights (CR-046) — standard AAOS ids and policy in [ExteriorLights]
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Status-checked read, only on a proven generation: an unpublished 0 would read as OFF. */
|
||||
private fun lightRead(propId: Int): Int? =
|
||||
if (ExteriorLights.isProven(FirmwareInfo.getGeneration()))
|
||||
readPropertyCPM(propId, AREA_GLOBAL, Int::class.javaObjectType) { sCpmGetIntMethod } as? Int
|
||||
else null
|
||||
|
||||
/** Low beams as VehicleLightState (0 off, 1 on, 2 daytime running); null when unreadable. */
|
||||
fun headlightsStateOrNull(): Int? = lightRead(ExteriorLights.PROP_HEADLIGHTS_STATE)
|
||||
|
||||
fun highBeamOnOrNull(): Boolean? =
|
||||
ExteriorLights.isOn(lightRead(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_STATE))
|
||||
|
||||
fun fogLightsOnOrNull(): Boolean? =
|
||||
ExteriorLights.isOn(lightRead(ExteriorLights.PROP_FOG_LIGHTS_STATE))
|
||||
|
||||
/** Switch positions, for the action editor's current value. */
|
||||
fun headlightsSwitchOrNull(): Int? = lightRead(ExteriorLights.PROP_HEADLIGHTS_SWITCH)
|
||||
|
||||
fun highBeamSwitchOrNull(): Int? = lightRead(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_SWITCH)
|
||||
|
||||
fun fogLightsSwitchOnOrNull(): Boolean? =
|
||||
lightRead(ExteriorLights.PROP_FOG_LIGHTS_SWITCH)?.let { it == ExteriorLights.SWITCH_ON }
|
||||
|
||||
/** Low beams ON or AUTOMATIC — never OFF ([ExteriorLights.HEADLIGHT_SWITCH_ALLOWED]). */
|
||||
@RequiresStandstill
|
||||
fun setHeadlightsSwitch(mode: Int): Boolean =
|
||||
lightWrite(ExteriorLights.PROP_HEADLIGHTS_SWITCH, mode, ExteriorLights.HEADLIGHT_SWITCH_ALLOWED)
|
||||
|
||||
/** High beam OFF or AUTOMATIC — never forced ON. */
|
||||
@RequiresStandstill
|
||||
fun setHighBeamSwitch(mode: Int): Boolean =
|
||||
lightWrite(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_SWITCH, mode, ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED)
|
||||
|
||||
@RequiresStandstill
|
||||
fun setFogLights(on: Boolean): Boolean = lightWrite(
|
||||
ExteriorLights.PROP_FOG_LIGHTS_SWITCH,
|
||||
if (on) ExteriorLights.SWITCH_ON else ExteriorLights.SWITCH_OFF,
|
||||
ExteriorLights.FOG_SWITCH_ALLOWED,
|
||||
)
|
||||
|
||||
/** Policy, then proven generation, then the 0 km/h gate inside [setIntPropertyCPM]. */
|
||||
private fun lightWrite(propId: Int, value: Int, allowed: Set<Int>): Boolean {
|
||||
ExteriorLights.writeRefusal(FirmwareInfo.getGeneration(), allowed, value)?.let {
|
||||
AppLogger.w(TAG, " light 0x${Integer.toHexString(propId)} refused: $it")
|
||||
return false
|
||||
}
|
||||
return setIntPropertyCPM(propId, AREA_GLOBAL, value)
|
||||
}
|
||||
|
||||
private inline fun <T> supportedTelemetryRead(read: () -> T?): T? =
|
||||
when (FirmwareInfo.getGeneration()) {
|
||||
FirmwareInfo.Gen.SWI68,
|
||||
@@ -1439,7 +1581,7 @@ object EVHardware {
|
||||
return try {
|
||||
val native = (g.javaClass.getMethod("getBrightness").invoke(g) as? Int) ?: return -1
|
||||
if (native < 0) return -1
|
||||
val pct = (native * 100 / BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
|
||||
val pct = ((native * 100 + BRIGHTNESS_NATIVE_MAX / 2) / BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
|
||||
AppLogger.d(TAG, " getBrightness native=$native → $pct%")
|
||||
pct
|
||||
} catch (e: Exception) {
|
||||
@@ -1450,7 +1592,7 @@ object EVHardware {
|
||||
|
||||
private fun setBrightnessOldSdk(clampedPct: Int): Boolean {
|
||||
val g = sGeneral ?: return false
|
||||
val native = (clampedPct * BRIGHTNESS_NATIVE_MAX / 100).coerceIn(0, BRIGHTNESS_NATIVE_MAX)
|
||||
val native = ((clampedPct * BRIGHTNESS_NATIVE_MAX + 50) / 100).coerceIn(0, BRIGHTNESS_NATIVE_MAX)
|
||||
if (logEnabled) AppLogger.i(TAG, "setBrightness → $clampedPct% (native=$native/255)")
|
||||
return try {
|
||||
g.javaClass.getMethod("setBrightness", Int::class.javaPrimitiveType).invoke(g, native)
|
||||
@@ -1477,12 +1619,12 @@ object EVHardware {
|
||||
AppLogger.w(TAG, " A9 getBrightness Settings.System exc: ${e.message}"); return -1
|
||||
}
|
||||
if (native < 0) return -1
|
||||
return (native * 100 / A9_BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
|
||||
return ((native * 100 + A9_BRIGHTNESS_NATIVE_MAX / 2) / A9_BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
|
||||
}
|
||||
|
||||
private fun setBrightnessA9(clampedPct: Int): Boolean {
|
||||
val resolver = sAppContext?.contentResolver ?: return false
|
||||
val native = (clampedPct.coerceIn(0, 100) * A9_BRIGHTNESS_NATIVE_MAX / 100).coerceIn(1, A9_BRIGHTNESS_NATIVE_MAX)
|
||||
val native = ((clampedPct.coerceIn(0, 100) * A9_BRIGHTNESS_NATIVE_MAX + 50) / 100).coerceIn(1, A9_BRIGHTNESS_NATIVE_MAX)
|
||||
return try {
|
||||
// Manual mode, otherwise auto-brightness overwrites the value at once
|
||||
android.provider.Settings.System.putInt(resolver,
|
||||
@@ -2329,23 +2471,22 @@ object EVHardware {
|
||||
fun getSeatHeatRight(): Int = seatHeatRightOrNull() ?: 0
|
||||
fun isSteeringHeatOn(): Boolean = steeringHeatOnOrNull() ?: false
|
||||
|
||||
fun getDriveMode(): DriveMode? {
|
||||
val cpm = sCarPropertyManager ?: return null
|
||||
return try {
|
||||
val raw = (cpm.javaClass
|
||||
.getMethod("getIntProperty", Int::class.java, Int::class.java)
|
||||
.invoke(cpm, PROP_DRIVE_MODE, AREA_GLOBAL) as? Int) ?: return null
|
||||
DriveMode.fromValue(raw)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
fun getDriveMode(): DriveMode? = getDriveModeRaw()?.let(DriveMode::fromValue)
|
||||
|
||||
fun getRegenLevel(): RegenLevel? {
|
||||
/** The drive mode code as the car sends it; null when it could not be read. */
|
||||
fun getDriveModeRaw(): Int? = readIntGlobal(PROP_DRIVE_MODE)
|
||||
|
||||
fun getRegenLevel(): RegenLevel? = getRegenLevelRaw()?.let(RegenLevel::fromValue)
|
||||
|
||||
/** The regen code as the car sends it: 6 is one-pedal on SWI68 (EVTasker CR-045 diff). */
|
||||
fun getRegenLevelRaw(): Int? = readIntGlobal(PROP_REGEN_LEVEL)
|
||||
|
||||
private fun readIntGlobal(prop: Int): Int? {
|
||||
val cpm = sCarPropertyManager ?: return null
|
||||
return try {
|
||||
val raw = (cpm.javaClass
|
||||
cpm.javaClass
|
||||
.getMethod("getIntProperty", Int::class.java, Int::class.java)
|
||||
.invoke(cpm, PROP_REGEN_LEVEL, AREA_GLOBAL) as? Int) ?: return null
|
||||
RegenLevel.fromValue(raw)
|
||||
.invoke(cpm, prop, AREA_GLOBAL) as? Int
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
@@ -2464,8 +2605,9 @@ object EVHardware {
|
||||
fun getAccTjaMode(): Int {
|
||||
val useNewApi = FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132
|
||||
val method = if (useNewApi) "getAccTjaState" else "getAccTjaMode"
|
||||
if (logEnabled) AppLogger.i(TAG, "$method →")
|
||||
return (callVsm(method) as? Int) ?: -1
|
||||
val value = (callVsm(method) as? Int) ?: -1
|
||||
if (logEnabled) AppLogger.i(TAG, "$method → $value")
|
||||
return value
|
||||
}
|
||||
|
||||
/** SWI68/SWI165 : setAccTjaMode(I)V SWI69/SWI131/SWI132 : setAccTjaState(I)V — void */
|
||||
@@ -2491,8 +2633,9 @@ object EVHardware {
|
||||
/** Lit le mode du limiteur de speed. Returns 0/2/3, ou -1 si unavailable. */
|
||||
fun getSpeedLimiterMode(): Int {
|
||||
val method = if (useSasApi()) "getSasMode" else "getSpeedAsstMode"
|
||||
if (logEnabled) AppLogger.i(TAG, "$method →")
|
||||
return (callVsm(method) as? Int) ?: -1
|
||||
val value = (callVsm(method) as? Int) ?: -1
|
||||
if (logEnabled) AppLogger.i(TAG, "$method → $value")
|
||||
return value
|
||||
}
|
||||
|
||||
/** Configure le mode du limiteur de speed. 0=Disabled, 2=Manual, 3=Intelligent. */
|
||||
@@ -4316,6 +4459,30 @@ object EVHardware {
|
||||
return if (answered) open else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Front-door evidence for a firmware the door feature does not claim yet, for EVTasker's
|
||||
* Diagnostic.
|
||||
*
|
||||
* MG4Control reads DLOCK_DOOR_OPEN_STS on SWI68/69/131/165 through the change
|
||||
* subscription, because the on-demand read throws there. Nothing here has seen a car
|
||||
* confirm that, so [frontDoorOpenOrNull] stays gated. This connects and subscribes
|
||||
* regardless of [hasDoorVolumeFeature] — the first call starts the connection, which is
|
||||
* asynchronous — and reports what came back: export with the front doors shut, open
|
||||
* one, export again. Proven when the event value follows the door.
|
||||
*/
|
||||
fun probeFrontDoors(): String {
|
||||
connectCarProperty()
|
||||
registerDoorCallback()
|
||||
val reads = DOOR_FRONT_AREAS.joinToString(" ") { area ->
|
||||
"read[0x${area.toString(16)}]=${readDoorOpen(area) ?: "?"}"
|
||||
}
|
||||
val events = synchronized(this) {
|
||||
DOOR_FRONT_AREAS.joinToString(" ") { area -> "event[0x${area.toString(16)}]=${sDoorReadLast[area] ?: "?"}" }
|
||||
}
|
||||
return "managers property=${sCarPropMgr != null} doorlock=${sCarDoorMgr != null} " +
|
||||
"subscribed property=$sDoorSubProperty doorlock=$sDoorSubDoorlock $reads $events"
|
||||
}
|
||||
|
||||
/** Diagnostic button probe: logs the volume + door states at click time. */
|
||||
fun runDoorVolumeDiag() {
|
||||
AppLogger.i(VOL_TAG, "── DIAG (bouton Diagnostic) ──")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.evsuite.hardware
|
||||
|
||||
/**
|
||||
* Exterior lights (CR-046): the standard AAOS ids, their values, and what a rule may do with them.
|
||||
*
|
||||
* Kept free of Android so the policy is pinned by JVM tests. The ids are the public
|
||||
* `VehicleProperty` ones (types.hal 2.0, available on AAOS 9); nothing here comes from a vendor
|
||||
* catalogue. Whether the MG4 VHAL publishes them is a per-generation question: a generation joins
|
||||
* [PROVEN] only with a capture showing the state follow the stalk — EVTasker's Diagnostic
|
||||
* `EXTERIOR_LIGHTS` row ([EVHardware.probeExteriorLights]), exported off and on — in the same commit as the
|
||||
* `@SupportedOn` of the light entries and their `writeProven`.
|
||||
*
|
||||
* Owner decisions (2026-10-08): writes only at 0 km/h like every vehicle write, and a rule may
|
||||
* never switch the low beams OFF. Same reasoning for the high beam: a rule may set it OFF or
|
||||
* AUTOMATIC, never force it ON — that dazzles, and the stalk stays the driver's.
|
||||
*/
|
||||
object ExteriorLights {
|
||||
|
||||
// VehicleProperty — SYSTEM | GLOBAL | INT32 | 0x0E0x.
|
||||
const val PROP_HEADLIGHTS_STATE = 0x11400E00
|
||||
const val PROP_HIGH_BEAM_LIGHTS_STATE = 0x11400E01
|
||||
const val PROP_FOG_LIGHTS_STATE = 0x11400E02
|
||||
const val PROP_HEADLIGHTS_SWITCH = 0x11400E10
|
||||
const val PROP_HIGH_BEAM_LIGHTS_SWITCH = 0x11400E11
|
||||
const val PROP_FOG_LIGHTS_SWITCH = 0x11400E12
|
||||
|
||||
// VehicleLightState
|
||||
const val STATE_OFF = 0
|
||||
const val STATE_ON = 1
|
||||
const val STATE_DAYTIME_RUNNING = 2
|
||||
|
||||
// VehicleLightSwitch
|
||||
const val SWITCH_OFF = 0
|
||||
const val SWITCH_ON = 1
|
||||
const val SWITCH_DAYTIME_RUNNING = 2
|
||||
const val SWITCH_AUTOMATIC = 0x100
|
||||
|
||||
/** Generations where the ids above are proven on the car. Empty until a capture says so. */
|
||||
val PROVEN: Set<FirmwareInfo.Gen> = emptySet()
|
||||
|
||||
/** Names for the capture diff, so a changed light property reads as itself. */
|
||||
val NAMES: Map<Int, String> = mapOf(
|
||||
PROP_HEADLIGHTS_STATE to "HEADLIGHTS_STATE",
|
||||
PROP_HIGH_BEAM_LIGHTS_STATE to "HIGH_BEAM_LIGHTS_STATE",
|
||||
PROP_FOG_LIGHTS_STATE to "FOG_LIGHTS_STATE",
|
||||
PROP_HEADLIGHTS_SWITCH to "HEADLIGHTS_SWITCH",
|
||||
PROP_HIGH_BEAM_LIGHTS_SWITCH to "HIGH_BEAM_LIGHTS_SWITCH",
|
||||
PROP_FOG_LIGHTS_SWITCH to "FOG_LIGHTS_SWITCH",
|
||||
)
|
||||
|
||||
/** Low beams: ON or AUTOMATIC. Never OFF. */
|
||||
val HEADLIGHT_SWITCH_ALLOWED = setOf(SWITCH_ON, SWITCH_AUTOMATIC)
|
||||
|
||||
/** High beam: OFF or AUTOMATIC. Never forced ON. */
|
||||
val HIGH_BEAM_SWITCH_ALLOWED = setOf(SWITCH_OFF, SWITCH_AUTOMATIC)
|
||||
|
||||
/** Fog lights: on or off. */
|
||||
val FOG_SWITCH_ALLOWED = setOf(SWITCH_OFF, SWITCH_ON)
|
||||
|
||||
fun isProven(gen: FirmwareInfo.Gen): Boolean = gen in PROVEN
|
||||
|
||||
/**
|
||||
* Why a light write must not be sent, or null when it may go on to the speed gate.
|
||||
* Policy first, so a forbidden value is refused on every car, proven or not.
|
||||
*/
|
||||
fun writeRefusal(
|
||||
gen: FirmwareInfo.Gen,
|
||||
allowed: Set<Int>,
|
||||
value: Int,
|
||||
proven: Set<FirmwareInfo.Gen> = PROVEN,
|
||||
): String? = when {
|
||||
value !in allowed -> "value 0x${Integer.toHexString(value)} not allowed for a rule"
|
||||
gen !in proven -> "exterior lights not proven on $gen"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** A light state read as "on"; daytime running lights are not headlights on. */
|
||||
fun isOn(state: Int?): Boolean? = state?.let { it == STATE_ON }
|
||||
}
|
||||
@@ -3,20 +3,44 @@ package com.evsuite.hardware
|
||||
import com.evsuite.hardware.catalog.SnapshotKeys
|
||||
|
||||
/**
|
||||
* Decodes `com.saic.keyevent.hardkey.report` payloads.
|
||||
* Decodes the head unit's two hard-key broadcasts.
|
||||
*
|
||||
* Codes 5/23/24/25/87/88/164/286/287 come from R69 EOL's
|
||||
* PhysicalKeysBroadcastReceiver; 287 is also named VOICE_KEY_CODE by VrSpeechService.
|
||||
* Codes 17 and 18 are firmware aliases already observed by EVProfile.
|
||||
* They number their keys in two different spaces, so a code only means something together
|
||||
* with the broadcast it came on ([Source]):
|
||||
*
|
||||
* - `com.saic.keyevent.hardkey.report` carries Android key codes. 5/23/24/25/87/88/164/286/287
|
||||
* come from R69 EOL's PhysicalKeysBroadcastReceiver; 287 is also VrSpeechService's
|
||||
* VOICE_KEY_CODE. 17 and 18 are firmware aliases already observed by EVProfile.
|
||||
* - `com.android.systemui.ACTION_HARD_KEY_EVENT` carries SAIC's own numbering: 1/2 volume,
|
||||
* 3 mute, 4/5 previous/next (mediaservice HardKeyMonitor, radioservice ReceiveUtils),
|
||||
* 6–10 the instrument-cluster pad (engineermodeservice KeyEventReceiver, `_IPK`), 16 phone
|
||||
* (btcallservice).
|
||||
*
|
||||
* The same physical press arrives on both. Read as one space, SystemUI's 5 (next track) was
|
||||
* decoded as the report's 5 (phone), and a phone press arriving twice read as a double.
|
||||
* So SystemUI keys the report already carries are dropped, and only the cluster pad — which
|
||||
* the report does not carry — is read from SystemUI.
|
||||
*
|
||||
* A key neither list names is still reported, as an unknown key with its own [Event.keyId],
|
||||
* so a driver can bind a button this table has not met yet.
|
||||
*/
|
||||
class PhysicalButtonEventDecoder {
|
||||
enum class Button(val codes: Set<Int>, val shortKey: String, val longKey: String) {
|
||||
PHONE(setOf(5, 16), SnapshotKeys.KEY_PHONE_SHORT, SnapshotKeys.KEY_PHONE_LONG),
|
||||
UP(setOf(6), SnapshotKeys.KEY_UP_SHORT, SnapshotKeys.KEY_UP_LONG),
|
||||
DOWN(setOf(7), SnapshotKeys.KEY_DOWN_SHORT, SnapshotKeys.KEY_DOWN_LONG),
|
||||
OK(setOf(8), SnapshotKeys.KEY_OK_SHORT, SnapshotKeys.KEY_OK_LONG),
|
||||
LEFT(setOf(9), SnapshotKeys.KEY_LEFT_SHORT, SnapshotKeys.KEY_LEFT_LONG),
|
||||
RIGHT(setOf(10), SnapshotKeys.KEY_RIGHT_SHORT, SnapshotKeys.KEY_RIGHT_LONG),
|
||||
enum class Source { HARDKEY_REPORT, SYSTEM_UI }
|
||||
|
||||
enum class Button(
|
||||
/** On the report broadcast. */
|
||||
val codes: Set<Int>,
|
||||
val shortKey: String,
|
||||
val longKey: String,
|
||||
/** On the SystemUI broadcast, for keys the report does not carry. */
|
||||
val systemUiCodes: Set<Int> = emptySet(),
|
||||
) {
|
||||
PHONE(setOf(5), SnapshotKeys.KEY_PHONE_SHORT, SnapshotKeys.KEY_PHONE_LONG),
|
||||
UP(emptySet(), SnapshotKeys.KEY_UP_SHORT, SnapshotKeys.KEY_UP_LONG, setOf(6)),
|
||||
DOWN(emptySet(), SnapshotKeys.KEY_DOWN_SHORT, SnapshotKeys.KEY_DOWN_LONG, setOf(7)),
|
||||
OK(emptySet(), SnapshotKeys.KEY_OK_SHORT, SnapshotKeys.KEY_OK_LONG, setOf(8)),
|
||||
LEFT(emptySet(), SnapshotKeys.KEY_LEFT_SHORT, SnapshotKeys.KEY_LEFT_LONG, setOf(9)),
|
||||
RIGHT(emptySet(), SnapshotKeys.KEY_RIGHT_SHORT, SnapshotKeys.KEY_RIGHT_LONG, setOf(10)),
|
||||
SOURCE(setOf(110), SnapshotKeys.KEY_SOURCE_SHORT, SnapshotKeys.KEY_SOURCE_LONG),
|
||||
CENTER(setOf(23), SnapshotKeys.KEY_CENTER_SHORT, SnapshotKeys.KEY_CENTER_LONG),
|
||||
VOLUME_UP(setOf(24), SnapshotKeys.KEY_VOLUME_UP_SHORT, SnapshotKeys.KEY_VOLUME_UP_LONG),
|
||||
@@ -26,15 +50,28 @@ class PhysicalButtonEventDecoder {
|
||||
MUTE(setOf(164), SnapshotKeys.KEY_MUTE_SHORT, SnapshotKeys.KEY_MUTE_LONG),
|
||||
STAR_LEFT(setOf(17), SnapshotKeys.KEY_STAR_LEFT_SHORT, SnapshotKeys.KEY_STAR_LEFT_LONG),
|
||||
STAR_RIGHT(setOf(286, 18), SnapshotKeys.KEY_STAR_RIGHT_SHORT, SnapshotKeys.KEY_STAR_RIGHT_LONG),
|
||||
ASSISTANT(setOf(287), SnapshotKeys.KEY_ASSISTANT_SHORT, SnapshotKeys.KEY_ASSISTANT_LONG)
|
||||
ASSISTANT(setOf(287), SnapshotKeys.KEY_ASSISTANT_SHORT, SnapshotKeys.KEY_ASSISTANT_LONG);
|
||||
|
||||
/** The number a rule stores for this button — the first code, as rules always stored. */
|
||||
val id: Int get() = (codes + systemUiCodes).first()
|
||||
|
||||
companion object {
|
||||
fun byId(id: Int): Button? = entries.firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
enum class Press { SHORT, LONG, DOUBLE }
|
||||
data class Event(val button: Button, val press: Press) {
|
||||
val value: String get() = "${button.name}:${press.name}"
|
||||
|
||||
/**
|
||||
* One decoded press. [keyId] is [Button.id] for a known button, and an [unknownKeyId]
|
||||
* otherwise.
|
||||
*/
|
||||
data class Event(val keyId: Int, val press: Press) {
|
||||
val button: Button? get() = Button.byId(keyId)
|
||||
val value: String get() = "${keyName(keyId)}:${press.name}"
|
||||
fun readings(): Map<String, Any> = mapOf(SnapshotKeys.KEY_PHYSICAL_BUTTON_EVENT to value)
|
||||
}
|
||||
private enum class State { DOWN, LONG_REPORTED }
|
||||
private val states = mutableMapOf<Button, State>()
|
||||
private val states = mutableMapOf<Int, State>()
|
||||
|
||||
/**
|
||||
* How long a second press has to arrive within to count as a double.
|
||||
@@ -42,7 +79,7 @@ class PhysicalButtonEventDecoder {
|
||||
* The stock steering controls use the same window, and it is short on purpose: longer
|
||||
* would make two deliberate single presses merge into one double.
|
||||
*/
|
||||
private val lastShortAt = mutableMapOf<Button, Long>()
|
||||
private val lastShortAt = mutableMapOf<Int, Long>()
|
||||
|
||||
/**
|
||||
* @param atMillis when the event happened. A monotonic default, and JVM-only so the
|
||||
@@ -53,27 +90,28 @@ class PhysicalButtonEventDecoder {
|
||||
down: Boolean,
|
||||
longPress: Boolean,
|
||||
atMillis: Long = System.nanoTime() / 1_000_000,
|
||||
source: Source = Source.HARDKEY_REPORT,
|
||||
): Event? {
|
||||
val button = Button.entries.firstOrNull { keyCode in it.codes } ?: return null
|
||||
val key = keyId(source, keyCode) ?: return null
|
||||
return when {
|
||||
down && longPress && states[button] != State.LONG_REPORTED -> {
|
||||
states[button] = State.LONG_REPORTED
|
||||
down && longPress && states[key] != State.LONG_REPORTED -> {
|
||||
states[key] = State.LONG_REPORTED
|
||||
// A long press ends the pairing: the release that follows must not become the
|
||||
// second half of a double the driver never made.
|
||||
lastShortAt.remove(button)
|
||||
Event(button, Press.LONG)
|
||||
lastShortAt.remove(key)
|
||||
Event(key, Press.LONG)
|
||||
}
|
||||
down -> { states.putIfAbsent(button, State.DOWN); null }
|
||||
states.remove(button) == State.DOWN -> {
|
||||
val previous = lastShortAt[button]
|
||||
down -> { states.putIfAbsent(key, State.DOWN); null }
|
||||
states.remove(key) == State.DOWN -> {
|
||||
val previous = lastShortAt[key]
|
||||
if (previous != null && atMillis - previous <= DOUBLE_TAP_MS) {
|
||||
// Consumed, so three presses read as one double and one single rather than
|
||||
// as two overlapping doubles.
|
||||
lastShortAt.remove(button)
|
||||
Event(button, Press.DOUBLE)
|
||||
lastShortAt.remove(key)
|
||||
Event(key, Press.DOUBLE)
|
||||
} else {
|
||||
lastShortAt[button] = atMillis
|
||||
Event(button, Press.SHORT)
|
||||
lastShortAt[key] = atMillis
|
||||
Event(key, Press.SHORT)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
@@ -91,5 +129,37 @@ class PhysicalButtonEventDecoder {
|
||||
* double — see EVTasker's vehicle service.
|
||||
*/
|
||||
const val DOUBLE_TAP_MS = 300L
|
||||
|
||||
/** SystemUI codes for keys the report broadcast already carries — see the class doc. */
|
||||
private val SYSTEM_UI_DUPLICATES = setOf(1, 2, 3, 4, 5, 16)
|
||||
private const val UNKNOWN_REPORT_BASE = 10_000
|
||||
private const val UNKNOWN_SYSTEM_UI_BASE = 20_000
|
||||
|
||||
/** Kept apart from every [Button.id] and from the other broadcast's codes. */
|
||||
fun unknownKeyId(source: Source, keyCode: Int): Int = keyCode + when (source) {
|
||||
Source.HARDKEY_REPORT -> UNKNOWN_REPORT_BASE
|
||||
Source.SYSTEM_UI -> UNKNOWN_SYSTEM_UI_BASE
|
||||
}
|
||||
|
||||
/** Where an unknown key id came from, as `(source, code)` — null for a known button. */
|
||||
fun unknownKey(keyId: Int): Pair<Source, Int>? = when {
|
||||
Button.byId(keyId) != null -> null
|
||||
keyId >= UNKNOWN_SYSTEM_UI_BASE -> Source.SYSTEM_UI to keyId - UNKNOWN_SYSTEM_UI_BASE
|
||||
keyId >= UNKNOWN_REPORT_BASE -> Source.HARDKEY_REPORT to keyId - UNKNOWN_REPORT_BASE
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun keyName(keyId: Int): String = Button.byId(keyId)?.name ?: "KEY_$keyId"
|
||||
|
||||
private fun keyId(source: Source, keyCode: Int): Int? {
|
||||
if (keyCode < 0) return null
|
||||
return when (source) {
|
||||
Source.HARDKEY_REPORT -> Button.entries.firstOrNull { keyCode in it.codes }?.id
|
||||
Source.SYSTEM_UI -> {
|
||||
if (keyCode in SYSTEM_UI_DUPLICATES) return null
|
||||
Button.entries.firstOrNull { keyCode in it.systemUiCodes }?.id
|
||||
}
|
||||
} ?: unknownKeyId(source, keyCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ enum class ActionGroup(@StringRes val labelRes: Int) {
|
||||
ENERGY(R.string.group_energy),
|
||||
AUDIO(R.string.group_audio),
|
||||
ADAS(R.string.group_adas),
|
||||
LIGHTS(R.string.group_lights),
|
||||
SYSTEM(R.string.group_system)
|
||||
}
|
||||
|
||||
@@ -165,6 +166,32 @@ enum class ActionType(
|
||||
"SET_SCREEN_BRIGHTNESS", currentKey = SnapshotKeys.KEY_BRIGHTNESS
|
||||
),
|
||||
|
||||
// ── Exterior lights (CR-046, gated) ──────────────────────────────────────
|
||||
// Owner decisions: 0 km/h only, never low beams OFF, never high beam forced ON — the
|
||||
// options below cannot express either, and ExteriorLights refuses them again at the write.
|
||||
// Unproven everywhere: empty @SupportedOn and writeProven = false until a capture and an
|
||||
// observed effect prove a generation.
|
||||
@SupportedOn
|
||||
SET_HEADLIGHTS(
|
||||
R.string.act_headlights, ActionGroup.LIGHTS,
|
||||
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HEADLIGHT_SWITCH_CHOICES),
|
||||
"SET_HEADLIGHTS", gated = true, currentKey = SnapshotKeys.KEY_HEADLIGHTS_SWITCH,
|
||||
writeProven = false
|
||||
),
|
||||
@SupportedOn
|
||||
SET_HIGH_BEAM(
|
||||
R.string.act_high_beam, ActionGroup.LIGHTS,
|
||||
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HIGH_BEAM_SWITCH_CHOICES),
|
||||
"SET_HIGH_BEAM", gated = true, currentKey = SnapshotKeys.KEY_HIGH_BEAM_SWITCH,
|
||||
writeProven = false
|
||||
),
|
||||
@SupportedOn
|
||||
SET_FOG_LIGHTS(
|
||||
R.string.act_fog_lights, ActionGroup.LIGHTS,
|
||||
ValueSpec.BOOL, "SET_FOG_LIGHTS", gated = true,
|
||||
currentKey = SnapshotKeys.KEY_FOG_LIGHTS_SWITCH, writeProven = false
|
||||
),
|
||||
|
||||
// ── Climate (vendor service — com.evsuite.hardware.saic.SaicClimate) ─────────
|
||||
// These are the calls the car's own HVAC screen makes, so what they do is not in doubt.
|
||||
// Not gated: changing the cabin temperature does not alter road behaviour, and a rule
|
||||
@@ -191,8 +218,13 @@ enum class ActionType(
|
||||
* A second action rather than a zone argument on the first: the driver's target is the
|
||||
* one every existing rule sets, and giving it a zone would have made every saved rule
|
||||
* carry a value it never chose.
|
||||
*
|
||||
* Not SWI68: the passenger target reads -1 there, the converter's answer for a zone
|
||||
* the car does not have, so nothing shows the write lands anywhere. EVTasker's
|
||||
* Diagnostic prints that raw read (`CLIMATE_ZONES`); a SWI68 that answers a real
|
||||
* target there is what would bring the action back.
|
||||
*/
|
||||
@SupportedOn(SWI68, SWI165)
|
||||
@SupportedOn(SWI165)
|
||||
SET_PASSENGER_TEMP(
|
||||
R.string.act_passenger_temp, ActionGroup.CLIMATE,
|
||||
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
|
||||
@@ -296,11 +328,15 @@ enum class ActionType(
|
||||
),
|
||||
|
||||
// ── Energy (vendor service — com.evsuite.hardware.saic.SaicCharging) ─────────
|
||||
// Unproven: the write sends a percent, but SWI68 reads the limit back as `5` (2026-10-13),
|
||||
// a code from a table nobody has read. Writing 80 into a coded field is a guess, so the
|
||||
// action is held back until one write is seen to set the limit shown on the car's screen.
|
||||
@SupportedOn(SWI68, SWI165)
|
||||
SET_CHARGE_LIMIT(
|
||||
R.string.act_charge_limit, ActionGroup.ENERGY,
|
||||
number(VehicleEnums.CHARGE_LIMIT_MIN, VehicleEnums.CHARGE_LIMIT_MAX, R.string.unit_percent),
|
||||
"SET_CHARGE_LIMIT", currentKey = SnapshotKeys.KEY_CHARGE_LIMIT
|
||||
"SET_CHARGE_LIMIT", currentKey = SnapshotKeys.KEY_CHARGE_LIMIT,
|
||||
writeProven = false
|
||||
),
|
||||
@SupportedOn(SWI68, SWI165)
|
||||
SET_CHARGING_ENABLED(
|
||||
|
||||
@@ -23,7 +23,8 @@ enum class ConditionGroup(@StringRes val labelRes: Int) {
|
||||
CLIMATE(R.string.group_climate),
|
||||
COMFORT(R.string.group_comfort),
|
||||
AUDIO(R.string.group_audio),
|
||||
ADAS(R.string.group_adas)
|
||||
ADAS(R.string.group_adas),
|
||||
LIGHTS(R.string.group_lights)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,8 +363,12 @@ enum class ConditionType(
|
||||
* The two are the same number until dual zone is on, which is exactly when a rule needs
|
||||
* to tell them apart — and there is no readable "dual zone" flag, so the honest way to
|
||||
* ask is to compare the two targets.
|
||||
*
|
||||
* Not SWI68: the service reads the passenger zone (area 68) and answers -1, which is what
|
||||
* its converter returns for a value out of range — a single-zone car has no such zone.
|
||||
* Seen on a SWI68 MG4 that read the driver's target fine in the same pass.
|
||||
*/
|
||||
@SupportedOn(SWI68, SWI165)
|
||||
@SupportedOn(SWI165)
|
||||
PASSENGER_TEMP(
|
||||
R.string.cond_passenger_temp, ConditionGroup.CLIMATE,
|
||||
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
|
||||
@@ -431,7 +436,12 @@ enum class ConditionType(
|
||||
number(0, 100, R.string.unit_percent),
|
||||
SnapshotKeys.KEY_WINDOW_REAR_RIGHT, comparable = true
|
||||
),
|
||||
@SupportedOn(SWI68, SWI165)
|
||||
/**
|
||||
* Not SWI68: `getDoorLock` reads back the property `setDoorLock` writes, a lock request
|
||||
* rather than a lock state. A SWI68 MG4 in use answered 0, which is neither of the two
|
||||
* named values, so the condition never had anything to say there.
|
||||
*/
|
||||
@SupportedOn(SWI165)
|
||||
DOORS_LOCKED(
|
||||
R.string.cond_doors_locked, ConditionGroup.CONTEXT,
|
||||
ValueSpec.BOOL, SnapshotKeys.KEY_DOORS_LOCKED
|
||||
@@ -473,6 +483,26 @@ enum class ConditionType(
|
||||
SnapshotKeys.KEY_BRIGHTNESS, comparable = true
|
||||
),
|
||||
|
||||
// ── Exterior lights (CR-046) ─────────────────────────────────────────────
|
||||
// Standard AAOS ids (ExteriorLights). Proven on no generation yet: @SupportedOn stays empty
|
||||
// until a capture shows the state follow the stalk, in the commit that fills
|
||||
// ExteriorLights.PROVEN.
|
||||
@SupportedOn
|
||||
HEADLIGHTS(
|
||||
R.string.cond_headlights, ConditionGroup.LIGHTS,
|
||||
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HEADLIGHT_STATES), SnapshotKeys.KEY_HEADLIGHTS
|
||||
),
|
||||
@SupportedOn
|
||||
HIGH_BEAM_ON(
|
||||
R.string.cond_high_beam, ConditionGroup.LIGHTS,
|
||||
ValueSpec.BOOL, SnapshotKeys.KEY_HIGH_BEAM
|
||||
),
|
||||
@SupportedOn
|
||||
FOG_LIGHTS_ON(
|
||||
R.string.cond_fog_lights, ConditionGroup.LIGHTS,
|
||||
ValueSpec.BOOL, SnapshotKeys.KEY_FOG_LIGHTS
|
||||
),
|
||||
|
||||
// ── Audio ────────────────────────────────────────────────────────────────
|
||||
// What is coming out of the speakers, and how loud. The picker groups them together for
|
||||
// the same reason the action catalogue does: a rule about the radio is written next to
|
||||
|
||||
@@ -60,6 +60,13 @@ object SnapshotKeys {
|
||||
const val KEY_MEDIA_VOLUME = "mediaVolume"
|
||||
const val KEY_MEDIA_VOLUME_MAX = "mediaVolumeMax"
|
||||
const val KEY_BRIGHTNESS = "brightnessPct"
|
||||
// Exterior lights (CR-046): states for conditions, switch positions for the action editor.
|
||||
const val KEY_HEADLIGHTS = "headlightsState"
|
||||
const val KEY_HIGH_BEAM = "highBeamOn"
|
||||
const val KEY_FOG_LIGHTS = "fogLightsOn"
|
||||
const val KEY_HEADLIGHTS_SWITCH = "headlightsSwitch"
|
||||
const val KEY_HIGH_BEAM_SWITCH = "highBeamSwitch"
|
||||
const val KEY_FOG_LIGHTS_SWITCH = "fogLightsSwitchOn"
|
||||
const val KEY_AEB_ENABLED = "aebEnabled"
|
||||
const val KEY_AEB_MODE = "aebMode"
|
||||
const val KEY_AEB_SENSITIVITY = "aebSensitivity"
|
||||
|
||||
@@ -176,4 +176,23 @@ object VehicleEnums {
|
||||
EnumOption(2, R.string.radio_band_fm),
|
||||
EnumOption(4, R.string.radio_band_dab)
|
||||
)
|
||||
|
||||
/** VehicleLightState of the low beams — see ExteriorLights. */
|
||||
val HEADLIGHT_STATES = listOf(
|
||||
EnumOption(0, R.string.light_off),
|
||||
EnumOption(1, R.string.light_on),
|
||||
EnumOption(2, R.string.light_daytime_running)
|
||||
)
|
||||
|
||||
/** What a rule may ask of the low beams: ON or AUTOMATIC, never OFF. */
|
||||
val HEADLIGHT_SWITCH_CHOICES = listOf(
|
||||
EnumOption(1, R.string.light_on),
|
||||
EnumOption(0x100, R.string.light_auto)
|
||||
)
|
||||
|
||||
/** What a rule may ask of the high beam: OFF or AUTOMATIC, never forced ON. */
|
||||
val HIGH_BEAM_SWITCH_CHOICES = listOf(
|
||||
EnumOption(0, R.string.light_off),
|
||||
EnumOption(0x100, R.string.light_auto)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.evsuite.hardware.diag
|
||||
|
||||
import com.google.gson.JsonObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.net.URL
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* Posts one diagnostic artifact to an inbox the driver runs at home, instead of a USB stick.
|
||||
*
|
||||
* The inbox is the driver's own server (a Node-RED flow on the house network, for one): the
|
||||
* app holds no account and no key, and nothing is sent unless the driver taps Send and
|
||||
* confirms the address. HTTPS only, no redirect followed, no credentials or query in the URL,
|
||||
* so a typo cannot send a car's report somewhere else in clear.
|
||||
*
|
||||
* The body is one JSON object, the same for every app, so one script on the desk unpacks them
|
||||
* all: `{"evsuite":1,"app","version","name","createdAtMs","contentBase64"}`. The content is the
|
||||
* artifact exactly as it would land on the stick — a zip, or a text report — base64-encoded.
|
||||
*
|
||||
* No Android dependency: [poster] is injectable, so a test checks the wire without a network.
|
||||
*/
|
||||
object DiagnosticInbox {
|
||||
|
||||
const val FORMAT_VERSION = 1
|
||||
|
||||
/** Above what a bundle is allowed to be on the stick, with base64's third on top. */
|
||||
const val MAX_CONTENT_BYTES = 1024 * 1024
|
||||
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
private const val READ_TIMEOUT_MS = 20_000
|
||||
|
||||
data class Artifact(
|
||||
val app: String,
|
||||
val version: String,
|
||||
val name: String,
|
||||
val createdAtMs: Long,
|
||||
val content: ByteArray,
|
||||
)
|
||||
|
||||
sealed interface Outcome {
|
||||
data class Sent(val status: Int) : Outcome
|
||||
data class Failed(val reason: String) : Outcome
|
||||
}
|
||||
|
||||
/** Why [url] is not an inbox address, or null when it is one. */
|
||||
fun refuse(url: String): String? {
|
||||
val uri = runCatching { URI(url.trim()) }.getOrNull() ?: return "not a URL"
|
||||
if (!uri.isAbsolute) return "not an absolute URL"
|
||||
if (!uri.scheme.equals("https", ignoreCase = true)) return "must be https"
|
||||
if (uri.host.isNullOrBlank()) return "no host"
|
||||
if (uri.userInfo != null) return "must not carry credentials"
|
||||
if (uri.query != null || uri.fragment != null) return "must not carry a query"
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun body(artifact: Artifact): String = JsonObject().apply {
|
||||
addProperty("evsuite", FORMAT_VERSION)
|
||||
addProperty("app", artifact.app)
|
||||
addProperty("version", artifact.version)
|
||||
addProperty("name", artifact.name)
|
||||
addProperty("createdAtMs", artifact.createdAtMs)
|
||||
addProperty("contentBase64", Base64.getEncoder().encodeToString(artifact.content))
|
||||
}.toString()
|
||||
|
||||
fun send(
|
||||
url: String,
|
||||
artifact: Artifact,
|
||||
poster: (String, String) -> Int = ::post,
|
||||
): Outcome {
|
||||
refuse(url)?.let { return Outcome.Failed(it) }
|
||||
if (artifact.content.size > MAX_CONTENT_BYTES) {
|
||||
return Outcome.Failed("artifact is ${artifact.content.size} bytes, over $MAX_CONTENT_BYTES")
|
||||
}
|
||||
return try {
|
||||
val status = poster(url.trim(), body(artifact))
|
||||
if (status in 200..299) Outcome.Sent(status) else Outcome.Failed("HTTP $status")
|
||||
} catch (e: Exception) {
|
||||
Outcome.Failed(e.javaClass.simpleName + (e.message?.let { ": $it" } ?: ""))
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(url: String, body: String): Int {
|
||||
val bytes = body.toByteArray(Charsets.UTF_8)
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
instanceFollowRedirects = false
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
setFixedLengthStreamingMode(bytes.size)
|
||||
}
|
||||
return try {
|
||||
connection.outputStream.use { it.write(bytes) }
|
||||
connection.responseCode
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,8 @@ enum class DriveMode(val value: Int, val label: String, @StringRes val labelRes:
|
||||
|
||||
companion object {
|
||||
fun fromValue(v: Int): DriveMode = values().firstOrNull { it.value == v } ?: NORMAL
|
||||
|
||||
/** Exact decode: a code this enum does not know is null, not a default. */
|
||||
fun ofRaw(v: Int?): DriveMode? = values().firstOrNull { it.value == v }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,5 +37,29 @@ data class DrivingProfile(
|
||||
val tsrEnabled: Boolean = false,
|
||||
val isDefault: Boolean = false,
|
||||
// [BT-PROFILES] MAC of the Bluetooth device tied to this profile (null = none)
|
||||
val btDeviceMac: String? = null
|
||||
val btDeviceMac: String? = null,
|
||||
// CUSTOM drive mode (CR-040) — CustomDrive index: 0=Eco/Comfort, 1=Normal, 2=Sport.
|
||||
// null = not configured, never written; profiles saved before this feature read as null.
|
||||
// Written only when driveMode is CUSTOM — the car ignores them in any other mode.
|
||||
val customPower: Int? = null,
|
||||
val customSteering: Int? = null,
|
||||
val customPedal: Int? = null,
|
||||
// Climate (CR-041) — null = the profile does not touch climate (every older profile).
|
||||
val climate: ProfileClimate? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* The climate a profile applies (CR-041). A null switch means "unchanged": the profile leaves
|
||||
* that control where the car has it, which is what lets a profile care about the setpoint
|
||||
* without turning the defrost off.
|
||||
*/
|
||||
data class ProfileClimate(
|
||||
val powerOn: Boolean = true,
|
||||
val autoOn: Boolean? = null,
|
||||
val acOn: Boolean? = null,
|
||||
val tempCelsius: Int? = null,
|
||||
val fanLevel: Int? = null,
|
||||
val recirculation: Boolean? = null,
|
||||
val frontDefrost: Boolean? = null,
|
||||
val rearDefrost: Boolean? = null
|
||||
)
|
||||
|
||||
@@ -13,5 +13,8 @@ enum class RegenLevel(val value: Int, val label: String, @StringRes val labelRes
|
||||
|
||||
companion object {
|
||||
fun fromValue(v: Int): RegenLevel = values().firstOrNull { it.value == v } ?: MEDIUM
|
||||
|
||||
/** Exact decode: a code this enum does not know is null, not a default. */
|
||||
fun ofRaw(v: Int?): RegenLevel? = values().firstOrNull { it.value == v }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.evsuite.hardware.probe
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.IBinder
|
||||
import com.evsuite.hardware.EVHardware
|
||||
import com.evsuite.hardware.FirmwareInfo
|
||||
@@ -61,6 +62,7 @@ object RuntimeInterfaceProbe {
|
||||
* it from a capture that has been up for a while.
|
||||
*/
|
||||
fun connect(context: Context) {
|
||||
packages = context.applicationContext.packageManager
|
||||
SaicHub.connect(context)
|
||||
engMode.connect(context)
|
||||
}
|
||||
@@ -276,6 +278,80 @@ object RuntimeInterfaceProbe {
|
||||
add(vehicleProperty())
|
||||
add(vehicleCharging())
|
||||
add(carDiagnostics())
|
||||
addAll(componentMap())
|
||||
}
|
||||
|
||||
/** Kept from [connect], so [componentMap] needs no extra argument from the callers. */
|
||||
@Volatile private var packages: PackageManager? = null
|
||||
|
||||
/**
|
||||
* Every service the vendor packages declare in their manifests, as PackageManager lists it.
|
||||
*
|
||||
* The hub maps above can only name what someone already guessed to ask for. This is the
|
||||
* other direction: what is installed, which services each package declares, and whether an
|
||||
* unprivileged caller may reach them — RI-001, RI-003, RI-004, RI-007 and RI-008 each start
|
||||
* by not knowing which component publishes their signal.
|
||||
*
|
||||
* Manifest metadata only. Nothing is bound, no transaction is sent and no vendor class is
|
||||
* loaded, so this cannot reach a write on any interface. Visibility of other packages holds
|
||||
* because the target is API 28; a later target needs `QUERY_ALL_PACKAGES`, and without it
|
||||
* this would read as a head unit with no vendor packages at all.
|
||||
*/
|
||||
fun componentMap(): List<InterfaceProbe> {
|
||||
val pm = packages ?: return listOf(
|
||||
InterfaceProbe(COMPONENT_MAP, null, BindVerdict.ABSENT, note = NOT_CONNECTED),
|
||||
)
|
||||
val names = try {
|
||||
pm.getInstalledPackages(0).map { it.packageName }
|
||||
.filter { name -> COMPONENT_PREFIXES.any { name.startsWith(it) } }.sorted()
|
||||
} catch (e: RuntimeException) {
|
||||
return listOf(
|
||||
InterfaceProbe(COMPONENT_MAP, null, BindVerdict.DENIED, note = e.javaClass.name),
|
||||
)
|
||||
}
|
||||
return listOf(componentSummary(names)) + names.take(MAX_COMPONENT_PACKAGES).map { name ->
|
||||
val services = try {
|
||||
pm.getPackageInfo(name, PackageManager.GET_SERVICES).services.orEmpty()
|
||||
.map { ServiceRow(it.name, it.exported, it.permission) }
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
return@map InterfaceProbe("$COMPONENT_MAP:$name", null, BindVerdict.ABSENT)
|
||||
} catch (e: RuntimeException) {
|
||||
return@map InterfaceProbe(
|
||||
"$COMPONENT_MAP:$name", null, BindVerdict.DENIED, note = e.javaClass.name,
|
||||
)
|
||||
}
|
||||
componentEntry(name, services)
|
||||
}
|
||||
}
|
||||
|
||||
/** One declared service, as the manifest states it. */
|
||||
internal data class ServiceRow(val name: String, val exported: Boolean, val permission: String?)
|
||||
|
||||
/** How many vendor packages there are, and how many of them the entries below list. */
|
||||
internal fun componentSummary(names: List<String>): InterfaceProbe = InterfaceProbe(
|
||||
COMPONENT_MAP, null, BindVerdict.ANSWERED,
|
||||
mapOf(
|
||||
"packages" to names.size.toString(),
|
||||
"listed" to minOf(names.size, MAX_COMPONENT_PACKAGES).toString(),
|
||||
),
|
||||
COMPONENT_NOTE,
|
||||
)
|
||||
|
||||
/**
|
||||
* One package's services, keyed by class name, the value kept as the manifest's own words.
|
||||
*
|
||||
* A package that declares none is still ANSWERED: the manifest was read and said so.
|
||||
*/
|
||||
internal fun componentEntry(pkg: String, services: List<ServiceRow>): InterfaceProbe {
|
||||
val values = LinkedHashMap<String, String?>()
|
||||
values["services"] = services.size.toString()
|
||||
services.take(MAX_COMPONENT_SERVICES).forEach {
|
||||
values[it.name] = "exported=${it.exported} permission=${it.permission}"
|
||||
}
|
||||
if (services.size > MAX_COMPONENT_SERVICES) {
|
||||
values["truncated"] = (services.size - MAX_COMPONENT_SERVICES).toString()
|
||||
}
|
||||
return InterfaceProbe("$COMPONENT_MAP:$pkg", null, BindVerdict.ANSWERED, values)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,6 +424,16 @@ object RuntimeInterfaceProbe {
|
||||
): InterfaceProbe {
|
||||
val target = binder
|
||||
?: return InterfaceProbe(label, descriptor, BindVerdict.ABSENT, note = note)
|
||||
// The hub may hand over a different interface at this bind point. Every call would then
|
||||
// fail the remote descriptor check, which reads as a refusal and is not one: the
|
||||
// interface is not here, so the entry is ABSENT and nothing is called.
|
||||
val answersAs = runCatching { target.interfaceDescriptor }.getOrNull()
|
||||
if (answersAs != null && answersAs != descriptor) {
|
||||
return InterfaceProbe(
|
||||
label, descriptor, BindVerdict.ABSENT,
|
||||
note = "$note; the handle answers as $answersAs, so nothing was called",
|
||||
)
|
||||
}
|
||||
val values = calls.mapValues { (_, call) ->
|
||||
val args = call.args.toTypedArray()
|
||||
when (call.readAs) {
|
||||
@@ -365,6 +451,18 @@ object RuntimeInterfaceProbe {
|
||||
*/
|
||||
private val CODE_SWEEP = (0x0..0x12).toList()
|
||||
|
||||
private const val COMPONENT_MAP = "componentMap"
|
||||
private val COMPONENT_PREFIXES = listOf("com.saicmotor.", "com.saicvehicleservice.")
|
||||
|
||||
/** A bundle is pasted and read; a few hundred lines of manifest is the useful end of it. */
|
||||
internal const val MAX_COMPONENT_PACKAGES = 64
|
||||
internal const val MAX_COMPONENT_SERVICES = 40
|
||||
|
||||
private const val NOT_CONNECTED = "connect() was not called, so there is no PackageManager"
|
||||
private const val COMPONENT_NOTE =
|
||||
"PackageManager GET_SERVICES only: nothing bound, called or loaded. exported=false or " +
|
||||
"a permission we do not hold means unreachable from EVSuite, not absent."
|
||||
|
||||
/**
|
||||
* What ABSENT means when there is no adapter client to ask. Carried on every swept entry
|
||||
* rather than stated once at the top of the bundle: a later session reads one entry, not the
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.evsuite.hardware.probe
|
||||
|
||||
import com.evsuite.hardware.AppLogger
|
||||
import com.evsuite.hardware.EVHardware
|
||||
import com.evsuite.hardware.FirmwareInfo
|
||||
import com.evsuite.hardware.saic.SaicCharging
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
|
||||
/**
|
||||
* Two read-only diagnostic tools. EVTasker's stable diagnostic screen calls [capture] from two
|
||||
* buttons (CR-045/CR-046, 2026-10-13), because the owner only installs stable builds; that is
|
||||
* safe only because **no setter is called from this file** — only the getters
|
||||
* [VendorSurfaceRules.readableGetters] admits.
|
||||
*
|
||||
* 1. [socSourcesReport] (CR-044): every place state of charge might come from, side by side,
|
||||
* for the generations where the uploader reads none.
|
||||
* 2. [capture] + [VendorSurfaceRules.diff] (CR-045): read everything, let the driver flip a
|
||||
* setting on the car's own screen at standstill, read again, list what changed. That is how
|
||||
* an unknown setting (Automatic High Beam) is found on a given firmware without taking an
|
||||
* identifier from anybody else's code. It works for any setting, and across a SOC change
|
||||
* too: capture, drive a few kilometres, capture again.
|
||||
*
|
||||
* Every value printed is a candidate. Only a lead proven per generation becomes a read path.
|
||||
*/
|
||||
object VendorSurfaceProbe {
|
||||
|
||||
private const val TAG = "EV_SURVEY"
|
||||
|
||||
/** Longest a capture may take before it stops and says so. */
|
||||
const val BUDGET_MS = 20_000L
|
||||
|
||||
/** Getter-name words that may carry battery state, for the SOC survey. */
|
||||
val SOC_WORDS = listOf(
|
||||
"soc", "battery", "batt", "bms", "energy", "endurance", "range", "electric",
|
||||
"charge", "mileage",
|
||||
)
|
||||
|
||||
/**
|
||||
* Everything readable, keyed by source: every CarPropertyManager property (when
|
||||
* [words] is empty) and every value getter of the vendor setting objects EVHardware holds.
|
||||
* Call off the main thread: these are binder calls.
|
||||
*/
|
||||
fun capture(words: List<String> = emptyList()): Map<String, String> {
|
||||
val deadline = System.currentTimeMillis() + BUDGET_MS
|
||||
val out = sortedMapOf<String, String>()
|
||||
if (words.isEmpty()) out += EVHardware.readAllCarProperties(deadline)
|
||||
for ((name, handle) in EVHardware.vendorSettingHandles()) {
|
||||
for (getter in VendorSurfaceRules.readableGetters(handle.javaClass, words)) {
|
||||
if (System.currentTimeMillis() > deadline) {
|
||||
out["$name ~"] = "time budget spent"
|
||||
break
|
||||
}
|
||||
out["$name.${getter.name}"] = try {
|
||||
VendorSurfaceRules.render(getter.invoke(handle))
|
||||
} catch (e: InvocationTargetException) {
|
||||
"ERR ${e.targetException?.javaClass?.simpleName}"
|
||||
} catch (e: Exception) {
|
||||
"ERR ${e.javaClass.simpleName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
AppLogger.i(TAG, "capture(${words.joinToString()}) → ${out.size} values")
|
||||
return out
|
||||
}
|
||||
|
||||
/** CR-044: where state of charge can come from on this car, and what each source says. */
|
||||
fun socSourcesReport(): String = buildString {
|
||||
appendLine("─── SOC sources (CR-044) — ${FirmwareInfo.getGeneration()} ───")
|
||||
appendLine("SaicCharging hub: ${if (SaicCharging.isAvailable) "bound" else "absent or not bound"}" +
|
||||
" → SOC ${SaicCharging.stateOfChargePercent() ?: "null"}")
|
||||
appendLine("Vendor SOC property (SWI68 path) → ${EVHardware.getVendorBatterySocPercent() ?: "null"}")
|
||||
EVHardware.probeTelemetryProperties()
|
||||
.filter { it.name in setOf("EV_BATTERY_LEVEL", "INFO_EV_BATTERY_CAPACITY", "RANGE_REMAINING") }
|
||||
.forEach { appendLine(it.toString()) }
|
||||
val getters = capture(SOC_WORDS)
|
||||
appendLine("Vendor getters with battery words: ${getters.size}")
|
||||
getters.forEach { (key, value) -> appendLine(" $key = $value") }
|
||||
appendLine("Candidates only. To find SOC among unnamed properties, use the before/after")
|
||||
appendLine("capture across a SOC change (a few km, or a charge).")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.evsuite.hardware.probe
|
||||
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
/**
|
||||
* The decisions behind [VendorSurfaceProbe], kept free of Android so the JVM tests reach them.
|
||||
*
|
||||
* Two diagnostic tools stand on these rules (CR-044, CR-045). Both answer "where does this car
|
||||
* publish X?" by READING what the runtime already exposes. Neither tool writes anything:
|
||||
* - the SOC survey reads every candidate source of state of charge side by side;
|
||||
* - the before/after capture reads everything twice — once before the driver flips a setting
|
||||
* on the car's own screen, once after — and lists what changed.
|
||||
*
|
||||
* Getters are named by the runtime class itself, not by any external catalogue. What changed is
|
||||
* a lead to prove per generation before it becomes a supported read or write.
|
||||
*/
|
||||
object VendorSurfaceRules {
|
||||
|
||||
/** Read-only name shapes: a getter answers, it does not act. */
|
||||
private val GETTER_PREFIXES = listOf("get", "is", "has")
|
||||
|
||||
/**
|
||||
* Words that make a "getter" something else: a call that starts, registers, resets or
|
||||
* connects can move state even when its name begins with `get`, and one that hands back a
|
||||
* service or a listener is plumbing, not a reading. Matched as whole camel-case words, so
|
||||
* `getHighBeamSetting` and `isAutoEnabled` stay readings while `getAndReset` does not.
|
||||
*/
|
||||
private val ACTION_WORDS = setOf(
|
||||
"set", "reset", "clear", "request", "start", "stop", "connect", "disconnect",
|
||||
"register", "unregister", "bind", "unbind", "toggle", "send", "write", "apply", "click",
|
||||
"and", "instance", "service", "binder", "context", "listener", "callback", "handler",
|
||||
"looper",
|
||||
)
|
||||
|
||||
/** `getAndResetCount` → [and, reset, count]. */
|
||||
fun words(name: String): List<String> =
|
||||
name.split(Regex("(?<=[a-z0-9])(?=[A-Z])|_")).filter { it.isNotEmpty() }
|
||||
.map { it.lowercase() }
|
||||
|
||||
/** Only values: anything returning an object could hand back a binder or a context. */
|
||||
private val VALUE_TYPES: Set<Class<*>> = setOf(
|
||||
Int::class.javaPrimitiveType!!, Long::class.javaPrimitiveType!!,
|
||||
Float::class.javaPrimitiveType!!, Double::class.javaPrimitiveType!!,
|
||||
Boolean::class.javaPrimitiveType!!, Short::class.javaPrimitiveType!!,
|
||||
Byte::class.javaPrimitiveType!!,
|
||||
Int::class.javaObjectType, Long::class.javaObjectType, Float::class.javaObjectType,
|
||||
Double::class.javaObjectType, Boolean::class.javaObjectType, Short::class.javaObjectType,
|
||||
Byte::class.javaObjectType, String::class.java,
|
||||
)
|
||||
|
||||
/** The getters of [cls] safe to call for a reading, sorted by name. */
|
||||
fun readableGetters(cls: Class<*>, keywords: List<String> = emptyList()): List<Method> =
|
||||
cls.methods
|
||||
.filter { isReadableGetter(it) }
|
||||
.filter { m -> keywords.isEmpty() || keywords.any { m.name.contains(it, ignoreCase = true) } }
|
||||
.distinctBy { it.name }
|
||||
.sortedBy { it.name }
|
||||
|
||||
fun isReadableGetter(m: Method): Boolean {
|
||||
if (m.parameterCount != 0) return false
|
||||
if (!Modifier.isPublic(m.modifiers) || Modifier.isStatic(m.modifiers)) return false
|
||||
if (m.declaringClass == Any::class.java) return false
|
||||
if (m.returnType !in VALUE_TYPES) return false
|
||||
val name = m.name
|
||||
val prefix = GETTER_PREFIXES.firstOrNull { name.startsWith(it) && name.length > it.length }
|
||||
?: return false
|
||||
if (!name[prefix.length].isUpperCase()) return false
|
||||
return words(name.substring(prefix.length)).none { it in ACTION_WORDS }
|
||||
}
|
||||
|
||||
data class Change(val key: String, val before: String?, val after: String?)
|
||||
|
||||
/**
|
||||
* What differs between two readings. A key present on one side only is a change too: a
|
||||
* property that starts answering when a feature is switched on is exactly the lead sought.
|
||||
*/
|
||||
fun diff(before: Map<String, String>, after: Map<String, String>): List<Change> =
|
||||
(before.keys + after.keys).sorted()
|
||||
.filter { before[it] != after[it] }
|
||||
.map { Change(it, before[it], after[it]) }
|
||||
|
||||
/** One line per value, shaped like the rest of the diagnostic report. */
|
||||
fun formatDiff(changes: List<Change>, beforeCount: Int, afterCount: Int): String = buildString {
|
||||
appendLine("Values read: $beforeCount before, $afterCount after — ${changes.size} changed")
|
||||
if (changes.isEmpty()) {
|
||||
appendLine(" nothing changed: the setting is not in what this app can read, or the")
|
||||
appendLine(" car had not applied it yet when the second capture was taken")
|
||||
}
|
||||
changes.forEach { appendLine(" ${label(it.key)}: ${it.before ?: "—"} → ${it.after ?: "—"}") }
|
||||
}
|
||||
|
||||
private val CPM_KEY = Regex("""^CPM 0x([0-9A-Fa-f]{8})/""")
|
||||
|
||||
/** `CPM 0x11400E00/0` → `CPM 0x11400E00/0 (HEADLIGHTS_STATE)` for a standard id we name. */
|
||||
fun label(key: String, names: Map<Int, String> = com.evsuite.hardware.ExteriorLights.NAMES): String {
|
||||
val id = CPM_KEY.find(key)?.groupValues?.get(1)?.toLongOrNull(16)?.toInt() ?: return key
|
||||
return names[id]?.let { "$key ($it)" } ?: key
|
||||
}
|
||||
|
||||
/** Arrays print their content; everything else its own text. */
|
||||
fun render(value: Any?): String = when (value) {
|
||||
null -> "null"
|
||||
is IntArray -> value.contentToString()
|
||||
is LongArray -> value.contentToString()
|
||||
is FloatArray -> value.contentToString()
|
||||
is BooleanArray -> value.contentToString()
|
||||
is ByteArray -> value.contentToString()
|
||||
is Array<*> -> value.contentDeepToString()
|
||||
else -> value.toString()
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ object SaicNavGuidance {
|
||||
* A `List<EVRoutPoiInfo>` as `Parcel.writeTypedList` lays it out: the count, then per item a
|
||||
* non-null marker and the bean's own three fields in the order its `writeToParcel` wrote
|
||||
* them. Written by hand because the bean is the vendor's class and this module does not
|
||||
* carry it — the layout is three lines and depending on a decompiled class would be worse.
|
||||
* carry it — the layout is three lines and depending on an OEM class would be worse.
|
||||
*/
|
||||
internal fun Parcel.writeEvRoutPoiList(pois: List<NavigationHandoff.Poi>) {
|
||||
writeInt(pois.size)
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.IBinder
|
||||
import com.evsuite.hardware.A9Climate
|
||||
import com.evsuite.hardware.GlassProbe
|
||||
import com.evsuite.hardware.VsmGlass
|
||||
import com.evsuite.hardware.catalog.VehicleEnums
|
||||
|
||||
/**
|
||||
* The vendor vehicle service the head unit's HVAC and charging screens talk to.
|
||||
@@ -173,6 +174,15 @@ object SaicClimate {
|
||||
fun fanLevelMax(): Int? = if (a9) null else level(TX_GET_MAX_AIR_VOLUME)
|
||||
fun driverTemp(): Int? = if (a9) A9Climate.driverTemp() else level(TX_GET_DRV_TEMP)
|
||||
fun passengerTemp(): Int? = if (a9) null else level(TX_GET_PSG_TEMP)
|
||||
|
||||
/**
|
||||
* The passenger target exactly as the service answers it, for the diagnostic only.
|
||||
*
|
||||
* [passengerTemp] drops -1, which is also what the service's converter returns for a
|
||||
* zone the car does not have: the raw number is what tells "single zone" from "the
|
||||
* read did not answer" (null).
|
||||
*/
|
||||
fun passengerTempState(): Int? = if (a9) null else SaicAidl.callInt(binder(), DESCRIPTOR, TX_GET_PSG_TEMP)
|
||||
fun frontDefrostOn(): Boolean? = if (a9) A9Climate.frontDefrostOn() else flag(TX_GET_FRONT_DEFROST)
|
||||
fun rearDefrostOn(): Boolean? = if (a9) A9Climate.rearDefrostOn() else flag(TX_GET_BACK_DEFROST)
|
||||
/** The service answers [TEMP_ERROR] for a reading it holds no value for, not an error. */
|
||||
@@ -254,8 +264,18 @@ object SaicCharging {
|
||||
|
||||
fun chargingStatus(): Int? = read(TX_GET_CHARGING_STATUS)
|
||||
|
||||
/** Charge limit in percent — charging stops here. */
|
||||
fun chargeLimitPercent(): Int? = read(TX_GET_CLOSE_SOC)
|
||||
/**
|
||||
* Charge limit in percent — charging stops here.
|
||||
*
|
||||
* Null outside the range the car accepts: SWI68 answered `5` on 2026-10-13, a code from a
|
||||
* table nobody has read, not a percent. Null sends every caller down its "limit
|
||||
* unreadable" path instead of planning a charge to 5 %.
|
||||
*/
|
||||
fun chargeLimitPercent(): Int? = chargeLimitOrNull(read(TX_GET_CLOSE_SOC))
|
||||
|
||||
internal fun chargeLimitOrNull(raw: Int?): Int? =
|
||||
raw?.takeIf { it in VehicleEnums.CHARGE_LIMIT_MIN..VehicleEnums.CHARGE_LIMIT_MAX }
|
||||
|
||||
fun setChargeLimitPercent(percent: Int): Boolean =
|
||||
SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_CLOSE_SOC, percent.coerceIn(0, 100))
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import com.google.gson.Gson
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* One day of the battery analyses, kept so that tomorrow can be compared with today (CP-087).
|
||||
*
|
||||
* Nothing here is measured: every field is copied from [StateOfHealthEstimator],
|
||||
* [CalibrationDrift] and [BatteryExposure] as they stood that day. A null is an analysis that
|
||||
* refused, never a zero.
|
||||
*/
|
||||
data class BatteryDigest(
|
||||
/** The local calendar day, `yyyy-MM-dd`. One record per day; the last write of a day wins. */
|
||||
val day: String,
|
||||
val healthPercent: Double?,
|
||||
/** Half-width of the health band, in points. Null where the estimate refused. */
|
||||
val healthBandPercent: Double?,
|
||||
val healthWindows: Int,
|
||||
val calibration: CalibrationVerdict?,
|
||||
val daysSinceFullCharge: Double?,
|
||||
val hoursAboveHighSoc: Double?,
|
||||
val equivalentFullCycles: Double?,
|
||||
val chargeCount: Int,
|
||||
) {
|
||||
companion object {
|
||||
fun of(
|
||||
day: String,
|
||||
health: StateOfHealthResult,
|
||||
drift: CalibrationDriftReport?,
|
||||
exposure: BatteryExposureReport?,
|
||||
charge: ChargeEnergyReport,
|
||||
): BatteryDigest {
|
||||
val estimate = (health as? StateOfHealthResult.Ready)?.estimate
|
||||
return BatteryDigest(
|
||||
day = day,
|
||||
healthPercent = estimate?.stateOfHealthPercent?.value,
|
||||
healthBandPercent = estimate?.stateOfHealthPercent?.uncertainty,
|
||||
healthWindows = estimate?.windowCount ?: 0,
|
||||
calibration = drift?.verdict,
|
||||
daysSinceFullCharge = drift?.daysSinceFullCharge,
|
||||
hoursAboveHighSoc = exposure?.hoursAboveHighSoc,
|
||||
equivalentFullCycles = exposure?.equivalentFullCycles,
|
||||
chargeCount = charge.charges.size,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What moved between two days by more than its own uncertainty, and only that.
|
||||
*
|
||||
* A health figure that wanders inside its band has not changed: saying so every morning
|
||||
* is the notification a driver turns off in a week.
|
||||
*/
|
||||
fun changes(previous: BatteryDigest?, current: BatteryDigest): List<DigestChange> {
|
||||
if (previous == null) return emptyList()
|
||||
return listOfNotNull(
|
||||
healthMoved(previous, current),
|
||||
bandNarrowed(previous, current),
|
||||
calibrationChanged(previous, current),
|
||||
)
|
||||
}
|
||||
|
||||
private fun healthMoved(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
|
||||
val from = previous.healthPercent ?: return null
|
||||
val to = current.healthPercent ?: return null
|
||||
val band = max(previous.healthBandPercent ?: 0.0, current.healthBandPercent ?: 0.0)
|
||||
if (abs(to - from) <= band) return null
|
||||
return DigestChange.HealthMoved(from, to, current.healthBandPercent ?: band)
|
||||
}
|
||||
|
||||
private fun bandNarrowed(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
|
||||
val from = previous.healthBandPercent
|
||||
val to = current.healthBandPercent ?: return null
|
||||
// A first estimate is a narrowing from nothing, and the driver has never seen it.
|
||||
if (from == null) {
|
||||
val health = current.healthPercent ?: return null
|
||||
return DigestChange.HealthBandNarrowed(null, to, health)
|
||||
}
|
||||
if (from - to < MIN_BAND_NARROWING_PERCENT) return null
|
||||
return DigestChange.HealthBandNarrowed(from, to, current.healthPercent ?: return null)
|
||||
}
|
||||
|
||||
private fun calibrationChanged(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
|
||||
val to = current.calibration ?: return null
|
||||
if (previous.calibration == to) return null
|
||||
return DigestChange.CalibrationChanged(previous.calibration, to, current.daysSinceFullCharge)
|
||||
}
|
||||
|
||||
/** A point of band is what one more watched window buys; less is rounding. */
|
||||
const val MIN_BAND_NARROWING_PERCENT = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface DigestChange {
|
||||
data class HealthMoved(val fromPercent: Double, val toPercent: Double, val bandPercent: Double) : DigestChange
|
||||
data class HealthBandNarrowed(
|
||||
val fromBandPercent: Double?,
|
||||
val toBandPercent: Double,
|
||||
val healthPercent: Double,
|
||||
) : DigestChange
|
||||
data class CalibrationChanged(
|
||||
val from: CalibrationVerdict?,
|
||||
val to: CalibrationVerdict,
|
||||
val daysSinceFullCharge: Double?,
|
||||
) : DigestChange
|
||||
}
|
||||
|
||||
/** A year of days, newest last. Same atomic write and quarantine as [BatteryLedgerStore]. */
|
||||
class BatteryDigestStore(
|
||||
private val target: File,
|
||||
private val maxDays: Int = MAX_DAYS,
|
||||
private val gson: Gson = Gson(),
|
||||
) {
|
||||
fun read(): List<BatteryDigest> {
|
||||
if (!target.exists()) return emptyList()
|
||||
val text = runCatching { target.readText() }.getOrNull() ?: return emptyList()
|
||||
val envelope = runCatching { gson.fromJson(text, Envelope::class.java) }.getOrNull()
|
||||
if (envelope == null || envelope.schemaVersion != SCHEMA_VERSION) {
|
||||
target.renameTo(File(target.parentFile, "${target.name}.quarantine.${System.currentTimeMillis()}"))
|
||||
return emptyList()
|
||||
}
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
return envelope.days.orEmpty().filter { it != null && it.day != null }.sortedBy { it.day }
|
||||
}
|
||||
|
||||
/** Replaces the record of the same day, or adds it; returns the day before it, if any. */
|
||||
fun upsert(digest: BatteryDigest): BatteryDigest? {
|
||||
val others = read().filter { it.day != digest.day }
|
||||
val previous = others.lastOrNull { it.day < digest.day }
|
||||
write((others + digest).sortedBy { it.day }.takeLast(maxDays.coerceAtLeast(1)))
|
||||
return previous
|
||||
}
|
||||
|
||||
private fun write(days: List<BatteryDigest>): Boolean {
|
||||
val bytes = gson.toJson(Envelope(SCHEMA_VERSION, days)).toByteArray(Charsets.UTF_8)
|
||||
target.parentFile?.mkdirs()
|
||||
val temp = File(target.parentFile, "${target.name}.${System.nanoTime()}.tmp")
|
||||
return try {
|
||||
FileOutputStream(temp).use { output ->
|
||||
output.write(bytes)
|
||||
output.fd.sync()
|
||||
}
|
||||
if (temp.renameTo(target)) true else {
|
||||
temp.delete()
|
||||
false
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
temp.delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private data class Envelope(val schemaVersion: Int = 0, val days: List<BatteryDigest>? = null)
|
||||
|
||||
companion object {
|
||||
const val SCHEMA_VERSION = 1
|
||||
const val MAX_DAYS = 365
|
||||
const val FILE_NAME = "battery-digest.json"
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,9 @@ class BatteryLedgerRecorder(
|
||||
|
||||
/** @return true when this sample earned an entry and it was written. */
|
||||
fun observe(snapshot: EnergySnapshot): Boolean {
|
||||
// A head unit woken by a night charge runs on its factory clock (late 2018) until it syncs.
|
||||
// The ledger is read in time order, so one such entry would sort first and split the charge.
|
||||
if (snapshot.timestampMs < MIN_PLAUSIBLE_CLOCK_MS) return false
|
||||
seed()
|
||||
integrate(snapshot)
|
||||
val soc = snapshot.socPercent?.takeIf { it.isFinite() } ?: return false
|
||||
@@ -231,5 +234,8 @@ class BatteryLedgerRecorder(
|
||||
const val SOC_STEP_PERCENT = 1.0f
|
||||
const val QUIET_INTERVAL_MS = 15 * 60 * 1000L
|
||||
const val MAX_SAMPLE_GAP_MS = 5_000L
|
||||
|
||||
/** 2025-01-01T00:00Z: no reading of this app can be older. Seen by MG4Control on the car. */
|
||||
const val MIN_PLAUSIBLE_CLOCK_MS = 1_735_689_600_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* How long the rest of a charge takes, from the driver's own watched charges (CP-089).
|
||||
*
|
||||
* The unit is minutes per point of charge, measured on [ChargeEnergy.steps]: the wall clock
|
||||
* between two ledger entries over the points between them. No kWh per point and no power sign
|
||||
* stand between the history and the answer. A band of [BAND_PERCENT] points that no watched
|
||||
* charge crossed is a refusal, never an extrapolation.
|
||||
*/
|
||||
data class ChargeDurationEstimate(
|
||||
val fromPercent: Double,
|
||||
val targetPercent: Int,
|
||||
/** Median charge per band, summed. */
|
||||
val minutes: Double,
|
||||
/** Fastest and slowest past charge per band, summed: the range the history allows. */
|
||||
val lowMinutes: Double,
|
||||
val highMinutes: Double,
|
||||
/** From [LAST_STRETCH_PERCENT] to the target, median; null when the path does not cross it. */
|
||||
val lastStretchMinutes: Double?,
|
||||
/** True when only charges at a similar outside temperature were used. */
|
||||
val similarTemperature: Boolean,
|
||||
)
|
||||
|
||||
sealed interface ChargeDurationResult {
|
||||
data class Ready(val estimate: ChargeDurationEstimate) : ChargeDurationResult
|
||||
data class Refused(val reason: ChargeDurationRefusal, val bandFromPercent: Int? = null) :
|
||||
ChargeDurationResult
|
||||
}
|
||||
|
||||
enum class ChargeDurationRefusal { AT_TARGET, NO_WATCHED_CHARGE, MISSING_BAND }
|
||||
|
||||
/** One band of the charge in progress against the same band in past charges. */
|
||||
data class ChargeCurveCheck(
|
||||
val bandFromPercent: Int,
|
||||
val minutesPerPoint: Double,
|
||||
val usualLowMinutesPerPoint: Double,
|
||||
val usualHighMinutesPerPoint: Double,
|
||||
val pastCharges: Int,
|
||||
) {
|
||||
/** Slower than the slowest past charge in that band: less power than the history's low edge. */
|
||||
val slower: Boolean get() = minutesPerPoint > usualHighMinutesPerPoint
|
||||
}
|
||||
|
||||
object ChargeDuration {
|
||||
|
||||
fun estimate(
|
||||
charges: List<ChargeEnergy>,
|
||||
socPercent: Double,
|
||||
targetPercent: Int,
|
||||
outsideCelsius: Double?,
|
||||
): ChargeDurationResult {
|
||||
if (socPercent >= targetPercent) return ChargeDurationResult.Refused(ChargeDurationRefusal.AT_TARGET)
|
||||
if (outsideCelsius != null) {
|
||||
val similar = charges.filter { charge ->
|
||||
val low = charge.minOutsideTempCelsius
|
||||
val high = charge.maxOutsideTempCelsius
|
||||
low != null && high != null && abs((low + high) / 2.0 - outsideCelsius) <= SIMILAR_TEMPERATURE_CELSIUS
|
||||
}
|
||||
val ready = along(history(similar), socPercent, targetPercent, similar = true)
|
||||
if (ready is ChargeDurationResult.Ready) return ready
|
||||
}
|
||||
return along(history(charges), socPercent, targetPercent, similar = false)
|
||||
}
|
||||
|
||||
fun curveCheck(charges: List<ChargeEnergy>, bandFromPercent: Int, minutesPerPoint: Double): ChargeCurveCheck? {
|
||||
val past = history(charges)[bandFromPercent / BAND_PERCENT].orEmpty()
|
||||
if (past.size < MIN_PAST_CHARGES) return null
|
||||
return ChargeCurveCheck(bandFromPercent, minutesPerPoint, past.min(), past.max(), past.size)
|
||||
}
|
||||
|
||||
/** Band index to one minutes-per-point figure per past charge that crossed it. */
|
||||
fun history(charges: List<ChargeEnergy>): Map<Int, List<Double>> {
|
||||
val byBand = HashMap<Int, MutableList<Double>>()
|
||||
charges.forEach { charge ->
|
||||
charge.steps.filter { it.toSocPercent > it.fromSocPercent && it.durationMs > 0 }
|
||||
.groupBy { band(it.midSocPercent) }
|
||||
.forEach { (band, steps) ->
|
||||
val points = steps.sumOf { it.toSocPercent - it.fromSocPercent }
|
||||
byBand.getOrPut(band) { ArrayList() } += steps.sumOf { it.durationMs } / MINUTE_MS / points
|
||||
}
|
||||
}
|
||||
return byBand
|
||||
}
|
||||
|
||||
private fun along(
|
||||
history: Map<Int, List<Double>>,
|
||||
socPercent: Double,
|
||||
targetPercent: Int,
|
||||
similar: Boolean,
|
||||
): ChargeDurationResult {
|
||||
if (history.isEmpty()) return ChargeDurationResult.Refused(ChargeDurationRefusal.NO_WATCHED_CHARGE)
|
||||
var minutes = 0.0
|
||||
var low = 0.0
|
||||
var high = 0.0
|
||||
var lastStretch = 0.0
|
||||
val first = band(socPercent)
|
||||
val last = ceil(targetPercent / BAND_PERCENT.toDouble()).toInt() - 1
|
||||
for (band in first..last) {
|
||||
val past = history[band]?.sorted()
|
||||
?: return ChargeDurationResult.Refused(ChargeDurationRefusal.MISSING_BAND, band * BAND_PERCENT)
|
||||
val from = maxOf(socPercent, band * BAND_PERCENT.toDouble())
|
||||
val to = minOf(targetPercent.toDouble(), (band + 1) * BAND_PERCENT.toDouble())
|
||||
val median = past[past.size / 2]
|
||||
minutes += (to - from) * median
|
||||
low += (to - from) * past.first()
|
||||
high += (to - from) * past.last()
|
||||
val stretchFrom = maxOf(from, LAST_STRETCH_PERCENT.toDouble())
|
||||
if (to > stretchFrom) lastStretch += (to - stretchFrom) * median
|
||||
}
|
||||
val crosses = socPercent < LAST_STRETCH_PERCENT && targetPercent > LAST_STRETCH_PERCENT
|
||||
return ChargeDurationResult.Ready(
|
||||
ChargeDurationEstimate(
|
||||
fromPercent = socPercent,
|
||||
targetPercent = targetPercent,
|
||||
minutes = minutes,
|
||||
lowMinutes = low,
|
||||
highMinutes = high,
|
||||
lastStretchMinutes = if (crosses) lastStretch else null,
|
||||
similarTemperature = similar,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun band(socPercent: Double): Int =
|
||||
floor(socPercent / BAND_PERCENT).toInt().coerceIn(0, 100 / BAND_PERCENT - 1)
|
||||
|
||||
const val BAND_PERCENT = 10
|
||||
const val LAST_STRETCH_PERCENT = 80
|
||||
const val SIMILAR_TEMPERATURE_CELSIUS = 8.0
|
||||
const val MIN_PAST_CHARGES = 3
|
||||
private const val MINUTE_MS = 60_000.0
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Which way this app's own integral moved while charge was entering the pack.
|
||||
*
|
||||
* [EnergySnapshot.batteryPowerKw] declares positive as energy leaving the battery, so an
|
||||
* integral that **falls** during a charge is the declared convention holding. One that rises is
|
||||
* the same convention inverted, and it matters well beyond a sign on a dashboard: the state of
|
||||
* health estimator reads `PACK_PAIR_INTEGRAL` windows as energy that *left* the pack, so an
|
||||
* inverted source would hand it discharge windows with the sign of a charge and the estimator
|
||||
* would refuse every one of them.
|
||||
*
|
||||
* Nothing here corrects anything. RI-002 asks what the car actually does and this reports it.
|
||||
*/
|
||||
enum class ChargePackSign {
|
||||
/** The integral fell, which is the convention [EnergySnapshot.batteryPowerKw] declares. */
|
||||
ENERGY_FELL,
|
||||
|
||||
/** The integral rose, so the property signs a charge the other way round. */
|
||||
ENERGY_ROSE,
|
||||
|
||||
/** Readable and level: the pack pair said nothing moved while the gauge said it did. */
|
||||
FLAT,
|
||||
|
||||
/** No charge was watched closely enough, or no pack reading was integrated at all. */
|
||||
UNREADABLE,
|
||||
|
||||
/** Two charges answered differently, which settles nothing and must not be averaged. */
|
||||
CONTRADICTORY,
|
||||
}
|
||||
|
||||
/**
|
||||
* What the car's own kWh counters did across a charge.
|
||||
*
|
||||
* `SaicCharging` transactions 75 and 77 are documented as *since the last charge*, so the
|
||||
* expected behaviour is [RESET] — and "expected" is the whole reason to measure it. A counter
|
||||
* that resets is what lets [StateOfHealthEstimator] tell one discharge window from the next; a
|
||||
* counter that never moves is a source that will never produce a window at all.
|
||||
*/
|
||||
enum class ChargeCounterBehaviour {
|
||||
/** A counter went backwards: the car cleared it, which is what a charge is said to do. */
|
||||
RESET,
|
||||
|
||||
/**
|
||||
* A counter rose during the charge, which "since the last charge" does not predict.
|
||||
*/
|
||||
ROSE,
|
||||
|
||||
/** Read at both ends and unchanged. */
|
||||
STATIC,
|
||||
|
||||
/** Never published on either end of any charge. */
|
||||
ABSENT,
|
||||
}
|
||||
|
||||
/**
|
||||
* One charge session measured in energy rather than in points of charge.
|
||||
*
|
||||
* [BatteryExposure] already segments the ledger into sessions and says how fast each one moved
|
||||
* the gauge. This adds the half nothing reads today: how much energy went in, which way the
|
||||
* pack pair signed it, and what the car's own counters did while it happened.
|
||||
*/
|
||||
data class ChargeEnergy(
|
||||
val session: BatteryChargeSession,
|
||||
/**
|
||||
* Whether this app was sampling throughout.
|
||||
*
|
||||
* The ledger earns an entry every quarter of an hour whatever else happens, so a longer
|
||||
* silence than that is the app having been down — a head unit asleep on a charger overnight
|
||||
* is the ordinary case, not an edge one. An unwatched charge still gained the charge it
|
||||
* gained; what it cannot carry is a power, because the integral stopped moving while nobody
|
||||
* was integrating.
|
||||
*/
|
||||
val watched: Boolean,
|
||||
val maxEntryGapMs: Long,
|
||||
/** Signed, as the integral moved. Negative is the declared convention; see [ChargePackSign]. */
|
||||
val packDeltaKwh: Double?,
|
||||
/** Magnitude over the wall clock, and only where the whole session was watched. */
|
||||
val meanPowerKw: Double?,
|
||||
val counterBehaviour: ChargeCounterBehaviour,
|
||||
val consumedDeltaKwh: Double?,
|
||||
val regeneratedDeltaKwh: Double?,
|
||||
/**
|
||||
* Odometer moved, when the car published one at both ends.
|
||||
*
|
||||
* A rise in charge is how a charge is detected here, and a long descent raises the charge
|
||||
* too. Distance is what tells the two apart, so a moving session is regeneration and is
|
||||
* never offered to the driver as "your last charge".
|
||||
*/
|
||||
val movedKm: Double?,
|
||||
/** Every distinct value the charging status held, in the order the ledger saw them. */
|
||||
val chargingStatuses: List<Int>,
|
||||
/**
|
||||
* Pack power between each pair of watched ledger entries, which the recorder writes once
|
||||
* per point of charge: this is the charge curve, one step per point. Empty when unwatched.
|
||||
*/
|
||||
val steps: List<ChargePowerStep> = emptyList(),
|
||||
/** Outside temperature over the session, named as such: the pack's own is unpublished. */
|
||||
val minOutsideTempCelsius: Double? = null,
|
||||
val maxOutsideTempCelsius: Double? = null,
|
||||
) {
|
||||
/** The fastest watched step, measured; null when no step was watched. */
|
||||
val peakPowerKw: Double? get() = steps.maxOfOrNull { it.powerKw }
|
||||
|
||||
/**
|
||||
* Whether this rise was a charge rather than regeneration.
|
||||
*
|
||||
* Three pieces of evidence, any one of them enough:
|
||||
* - the odometer stood still;
|
||||
* - the car reported a non-zero charging status during the rise (every drive recorded so far
|
||||
* read zero throughout, 1 473 samples on SWI68);
|
||||
* - the rise is too large to be regeneration **and** nobody watched the car move during it.
|
||||
* A head unit asleep on the charger is the ordinary overnight case: the ledger then holds
|
||||
* the evening entry and the morning one, the odometer may have moved between them because
|
||||
* the drive home was never recorded either, and requiring it to stand still is what made
|
||||
* a whole night's charge read as a descent and vanish from the battery page.
|
||||
*/
|
||||
val plugged: Boolean
|
||||
get() {
|
||||
val moved = movedKm
|
||||
if (moved != null && moved <= STATIONARY_TOLERANCE_KM) return true
|
||||
if (chargingStatuses.any { it != 0 }) return true
|
||||
return session.gainedPercent >= UNAMBIGUOUS_CHARGE_PERCENT && (moved == null || !watched)
|
||||
}
|
||||
|
||||
val sign: ChargePackSign
|
||||
get() {
|
||||
val delta = packDeltaKwh
|
||||
if (!watched || delta == null) return ChargePackSign.UNREADABLE
|
||||
return when {
|
||||
delta <= -ChargeEnergyAnalyzer.MIN_ENERGY_KWH -> ChargePackSign.ENERGY_FELL
|
||||
delta >= ChargeEnergyAnalyzer.MIN_ENERGY_KWH -> ChargePackSign.ENERGY_ROSE
|
||||
else -> ChargePackSign.FLAT
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The odometer reads in whole kilometres, so anything under one is standing still. */
|
||||
const val STATIONARY_TOLERANCE_KM = 0.5
|
||||
|
||||
/**
|
||||
* A rise no descent produces. Regeneration on the steepest ordinary pass returns a few
|
||||
* points; ten points of a 61,7 kWh pack is over 6 kWh of potential energy recovered.
|
||||
*/
|
||||
const val UNAMBIGUOUS_CHARGE_PERCENT = 10.0
|
||||
}
|
||||
}
|
||||
|
||||
/** One watched step of a charge: from one ledger entry to the next, usually one point. */
|
||||
data class ChargePowerStep(
|
||||
val fromSocPercent: Double,
|
||||
val toSocPercent: Double,
|
||||
val durationMs: Long,
|
||||
/** Magnitude of the pack-pair integral's move over the step's wall clock. */
|
||||
val powerKw: Double,
|
||||
) {
|
||||
val midSocPercent: Double get() = (fromSocPercent + toSocPercent) / 2.0
|
||||
}
|
||||
|
||||
/** Time-weighted mean power over every watched step whose midpoint fell in one charge band. */
|
||||
data class SocBandPower(
|
||||
val fromPercent: Int,
|
||||
val toPercent: Int,
|
||||
val meanPowerKw: Double,
|
||||
val hours: Double,
|
||||
)
|
||||
|
||||
/** Every charge the ledger holds, and the two questions the set of them settles. */
|
||||
data class ChargeEnergyReport(
|
||||
val charges: List<ChargeEnergy>,
|
||||
val packSign: ChargePackSign,
|
||||
val counterBehaviour: ChargeCounterBehaviour,
|
||||
val chargingStatuses: List<Int>,
|
||||
) {
|
||||
/** The most recent charge that stood still, which is the one a driver means by "my charge". */
|
||||
val lastPluggedCharge: ChargeEnergy? get() = charges.lastOrNull { it.plugged }
|
||||
|
||||
/** Every charge that was a charge, oldest first. */
|
||||
val pluggedCharges: List<ChargeEnergy> get() = charges.filter { it.plugged }
|
||||
|
||||
/**
|
||||
* The charge curve across every watched charge: mean power per band of [bandPercent]
|
||||
* points of charge. This is where a taper shows — the pack accepting less as it fills —
|
||||
* and it is measured, one step per point, never modelled.
|
||||
*/
|
||||
fun powerBySocBand(bandPercent: Int = SOC_BAND_PERCENT): List<SocBandPower> =
|
||||
pluggedCharges.flatMap { it.steps }
|
||||
.groupBy { (it.midSocPercent / bandPercent).toInt().coerceIn(0, 100 / bandPercent - 1) }
|
||||
.toSortedMap()
|
||||
.map { (band, steps) ->
|
||||
val ms = steps.sumOf { it.durationMs }.toDouble()
|
||||
SocBandPower(
|
||||
fromPercent = band * bandPercent,
|
||||
toPercent = (band + 1) * bandPercent,
|
||||
meanPowerKw = steps.sumOf { it.powerKw * it.durationMs } / ms,
|
||||
hours = ms / 3_600_000.0,
|
||||
)
|
||||
}
|
||||
|
||||
val watchedCount: Int get() = charges.count { it.watched }
|
||||
|
||||
/**
|
||||
* The report as lines, because the answer belongs in the diagnostic bundle rather than
|
||||
* only on a screen. Same reason [com.evsuite.hardware.telemetry.model.SocConsumptionFitter]
|
||||
* describes itself: a ticket is settled by a bundle a driver brought back, not by a UI.
|
||||
*/
|
||||
fun describe(): List<String> {
|
||||
if (charges.isEmpty()) return listOf("charges=0")
|
||||
val lines = ArrayList<String>()
|
||||
lines += "charges=${charges.size} watched=$watchedCount"
|
||||
lines += "pack_pair_sign=${packSign.name}"
|
||||
lines += "vehicle_counters=${counterBehaviour.name}"
|
||||
lines += "charging_status_values=" + chargingStatuses.joinToString(",").ifEmpty { "none" }
|
||||
charges.takeLast(MAX_DESCRIBED_CHARGES).forEach { charge ->
|
||||
lines += "charge started_epoch_ms=${charge.session.startedAtMs}" +
|
||||
" gained_percent=${format(charge.session.gainedPercent)}" +
|
||||
" hours=${format(charge.session.durationHours)}" +
|
||||
" watched=${charge.watched}" +
|
||||
" max_entry_gap_ms=${charge.maxEntryGapMs}" +
|
||||
// Unwatched, the integral only moved during the few seconds the app was up: a
|
||||
// number there reads as a measured charge of nearly nothing.
|
||||
" pack_delta_kwh=${format(charge.packDeltaKwh?.takeIf { charge.watched })}" +
|
||||
" mean_power_kw=${format(charge.meanPowerKw)}" +
|
||||
" consumed_delta_kwh=${format(charge.consumedDeltaKwh)}" +
|
||||
" regenerated_delta_kwh=${format(charge.regeneratedDeltaKwh)}" +
|
||||
" moved_km=${format(charge.movedKm)}" +
|
||||
" counters=${charge.counterBehaviour.name}" +
|
||||
" sign=${charge.sign.name}" +
|
||||
" plugged=${charge.plugged}" +
|
||||
" peak_power_kw=${format(charge.peakPowerKw)}" +
|
||||
" steps=${charge.steps.size}"
|
||||
}
|
||||
powerBySocBand().forEach {
|
||||
lines += "soc_band ${it.fromPercent}-${it.toPercent} mean_power_kw=${format(it.meanPowerKw)}" +
|
||||
" hours=${format(it.hours)}"
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private fun format(value: Double?): String =
|
||||
if (value == null) "unavailable" else String.format(Locale.ROOT, "%.2f", value)
|
||||
|
||||
companion object {
|
||||
/** A bundle is bounded; the newest charges are the ones a question is asked about. */
|
||||
const val MAX_DESCRIBED_CHARGES = 8
|
||||
|
||||
const val SOC_BAND_PERCENT = 10
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The energy side of a charge, read from the ledger that is already being written.
|
||||
*
|
||||
* Nothing new is sampled and no new vehicle call is made. [BatteryLedgerRecorder] has been
|
||||
* recording the charge gauge, the car's counters, the integrated pack energy and the odometer
|
||||
* since CP-070; [BatteryExposure] already cuts that stream into charge sessions. What was
|
||||
* missing is a reader: the pack's health is estimated from discharges alone, so the energy
|
||||
* *entering* the pack — and with it the two facts RI-002 has been blocked on — was recorded and
|
||||
* never looked at.
|
||||
*
|
||||
* **Sessions are not re-segmented here.** They arrive from [BatteryExposure], so the two
|
||||
* screens can never disagree about what counts as a charge.
|
||||
*/
|
||||
class ChargeEnergyAnalyzer(
|
||||
private val maxWatchedGapMs: Long = MAX_WATCHED_GAP_MS,
|
||||
) {
|
||||
fun analyse(
|
||||
entries: List<BatteryLedgerEntry>,
|
||||
sessions: List<BatteryChargeSession>,
|
||||
): ChargeEnergyReport {
|
||||
val ordered = entries.sortedBy { it.atMs }
|
||||
val charges = sessions.map { measure(ordered, it) }
|
||||
return ChargeEnergyReport(
|
||||
charges = charges,
|
||||
packSign = consensusSign(charges),
|
||||
counterBehaviour = mostInformative(charges.map { it.counterBehaviour }),
|
||||
chargingStatuses = charges.flatMap { it.chargingStatuses }.distinct().sorted(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun measure(
|
||||
ordered: List<BatteryLedgerEntry>,
|
||||
session: BatteryChargeSession,
|
||||
): ChargeEnergy {
|
||||
val slice = ordered.filter { it.atMs in session.startedAtMs..session.endedAtMs }
|
||||
var gap = 0L
|
||||
for (index in 1 until slice.size) gap = max(gap, slice[index].atMs - slice[index - 1].atMs)
|
||||
val watched = slice.size >= 2 && gap <= maxWatchedGapMs
|
||||
val packDelta = span(slice.mapNotNull { it.packEnergyKwh })
|
||||
val consumed = span(slice.mapNotNull { it.vehicleConsumedKwh?.toDouble() })
|
||||
val regenerated = span(slice.mapNotNull { it.vehicleRegeneratedKwh?.toDouble() })
|
||||
val hours = session.durationHours
|
||||
val temps = slice.mapNotNull { it.outsideTempCelsius?.toDouble() }
|
||||
return ChargeEnergy(
|
||||
session = session,
|
||||
watched = watched,
|
||||
maxEntryGapMs = gap,
|
||||
packDeltaKwh = packDelta,
|
||||
meanPowerKw = if (watched && packDelta != null && hours > 0.0) {
|
||||
abs(packDelta) / hours
|
||||
} else {
|
||||
null
|
||||
},
|
||||
counterBehaviour = behaviourOf(consumed, regenerated),
|
||||
consumedDeltaKwh = consumed,
|
||||
regeneratedDeltaKwh = regenerated,
|
||||
movedKm = span(slice.mapNotNull { it.odometerKm?.toDouble() }),
|
||||
chargingStatuses = slice.mapNotNull { it.chargingStatus }.distinct(),
|
||||
steps = if (watched) steps(slice) else emptyList(),
|
||||
minOutsideTempCelsius = temps.minOrNull(),
|
||||
maxOutsideTempCelsius = temps.maxOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Adjacent pairs that both carry the integral; a pair with none says nothing and is dropped. */
|
||||
private fun steps(slice: List<BatteryLedgerEntry>): List<ChargePowerStep> =
|
||||
slice.zipWithNext().mapNotNull { (a, b) ->
|
||||
val from = a.packEnergyKwh ?: return@mapNotNull null
|
||||
val to = b.packEnergyKwh ?: return@mapNotNull null
|
||||
val ms = b.atMs - a.atMs
|
||||
if (ms <= 0L || !from.isFinite() || !to.isFinite()) return@mapNotNull null
|
||||
ChargePowerStep(
|
||||
fromSocPercent = a.socPercent.toDouble(),
|
||||
toSocPercent = b.socPercent.toDouble(),
|
||||
durationMs = ms,
|
||||
powerKw = abs(to - from) / (ms / 3_600_000.0),
|
||||
)
|
||||
}
|
||||
|
||||
/** Last reading minus first, or null when fewer than two of them were published. */
|
||||
private fun span(values: List<Double>): Double? {
|
||||
val finite = values.filter { it.isFinite() }
|
||||
return if (finite.size < 2) null else finite.last() - finite.first()
|
||||
}
|
||||
|
||||
private fun behaviourOf(consumed: Double?, regenerated: Double?): ChargeCounterBehaviour {
|
||||
val deltas = listOfNotNull(consumed, regenerated)
|
||||
if (deltas.isEmpty()) return ChargeCounterBehaviour.ABSENT
|
||||
if (deltas.any { it <= -COUNTER_EPSILON_KWH }) return ChargeCounterBehaviour.RESET
|
||||
if (deltas.any { it >= COUNTER_EPSILON_KWH }) return ChargeCounterBehaviour.ROSE
|
||||
return ChargeCounterBehaviour.STATIC
|
||||
}
|
||||
|
||||
/**
|
||||
* One answer from several charges, or an admission that there is not one.
|
||||
*
|
||||
* Two charges that signed the pack pair differently is not a figure to average: it says the
|
||||
* property means something this app has not understood, and reporting either half of it as
|
||||
* the answer would settle RI-002 wrongly.
|
||||
*/
|
||||
private fun consensusSign(charges: List<ChargeEnergy>): ChargePackSign {
|
||||
val signs = charges.map { it.sign }
|
||||
val decided = signs.filter {
|
||||
it == ChargePackSign.ENERGY_FELL || it == ChargePackSign.ENERGY_ROSE
|
||||
}.distinct()
|
||||
return when {
|
||||
decided.size > 1 -> ChargePackSign.CONTRADICTORY
|
||||
decided.size == 1 -> decided.first()
|
||||
signs.contains(ChargePackSign.FLAT) -> ChargePackSign.FLAT
|
||||
else -> ChargePackSign.UNREADABLE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A reset seen once is a fact about the car; a static reading elsewhere only says that
|
||||
* charge did not end where the car clears its counters. So the set is reported by its most
|
||||
* informative member rather than refused as a contradiction.
|
||||
*/
|
||||
private fun mostInformative(behaviours: List<ChargeCounterBehaviour>): ChargeCounterBehaviour =
|
||||
INFORMATIVENESS.firstOrNull { behaviours.contains(it) } ?: ChargeCounterBehaviour.ABSENT
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How long the ledger may go quiet inside a charge before it stops being watched.
|
||||
*
|
||||
* Twice [BatteryLedgerRecorder.QUIET_INTERVAL_MS]: the recorder earns an entry every
|
||||
* quarter of an hour on its own, so a longer silence is this app having been down rather
|
||||
* than a slow charge. The margin covers a sampler that stopped for one interval.
|
||||
*/
|
||||
const val MAX_WATCHED_GAP_MS = 2 * BatteryLedgerRecorder.QUIET_INTERVAL_MS
|
||||
|
||||
/**
|
||||
* Below this a charge says nothing about the sign.
|
||||
*
|
||||
* A watched charge of any length moves the integral by kilowatt-hours; half of one is
|
||||
* comfortably below anything real and comfortably above the drift of a pack pair read
|
||||
* at rest.
|
||||
*/
|
||||
const val MIN_ENERGY_KWH = 0.5
|
||||
|
||||
/** The car's counters step by a whole kilowatt-hour, so anything above noise is a move. */
|
||||
const val COUNTER_EPSILON_KWH = 0.05
|
||||
|
||||
private val INFORMATIVENESS = listOf(
|
||||
ChargeCounterBehaviour.RESET,
|
||||
ChargeCounterBehaviour.ROSE,
|
||||
ChargeCounterBehaviour.STATIC,
|
||||
ChargeCounterBehaviour.ABSENT,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.YearMonth
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* What a drive asked of the pack at the moments a pack minds most (CP-090): hard discharge at a
|
||||
* low charge or in the cold, and regeneration pushed into a cold pack.
|
||||
*
|
||||
* Measured quantities only, never a wear figure. "Hard" is the trip's own: discharge above
|
||||
* [HARD_SHARE] of that trip's peak, so a gentle driver's hardest moments count as well as a
|
||||
* heavy one's.
|
||||
*
|
||||
* [TripSample.batteryPowerKw]'s sign is unvalidated on this car (RI-002), so each trip orients
|
||||
* itself: the charge fell, so the way the power integral moved over the trip is discharge. A trip
|
||||
* whose charge did not fall, or whose integral did not move, cannot say which way is which and
|
||||
* gives no figure.
|
||||
*/
|
||||
data class DrivingStress(
|
||||
/** Minutes of hard discharge below [LOW_SOC_PERCENT] charge or below [COLD_CELSIUS] outside. */
|
||||
val stressedMinutes: Double,
|
||||
/** Energy regenerated below [COLD_CELSIUS] outside; null when the temperature was never read. */
|
||||
val coldRegeneratedKwh: Double?,
|
||||
) {
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Null when the trip cannot say: no orientation, or more than [MAX_UNDECIDED_SHARE] of
|
||||
* its time with an unread power, or a hard moment with an unread charge or temperature.
|
||||
*/
|
||||
fun of(samples: List<TripSample>): DrivingStress? {
|
||||
val socs = samples.mapNotNull { it.socPercent }
|
||||
if (socs.size < 2 || socs.first() - socs.last() < MIN_SOC_DROP) return null
|
||||
val segments = samples.zipWithNext().filter { (a, b) -> b.atMs - a.atMs in 1..MAX_GAP_MS }
|
||||
val net = segments.sumOf { (a, b) -> (a.batteryPowerKw ?: 0f) * hours(a, b) }
|
||||
val orientation = when {
|
||||
net >= MIN_NET_KWH -> 1.0
|
||||
net <= -MIN_NET_KWH -> -1.0
|
||||
else -> return null
|
||||
}
|
||||
val peak = samples.mapNotNull { it.batteryPowerKw }.maxOfOrNull { it * orientation } ?: return null
|
||||
if (peak <= 0.0) return null
|
||||
|
||||
var totalMs = 0L
|
||||
var undecidedMs = 0L
|
||||
var stressedMs = 0L
|
||||
var coldKnown = false
|
||||
var coldRegen = 0.0
|
||||
segments.forEach { (a, b) ->
|
||||
val ms = b.atMs - a.atMs
|
||||
totalMs += ms
|
||||
// An unread power could have been a hard one.
|
||||
val power = a.batteryPowerKw ?: run { undecidedMs += ms; return@forEach }
|
||||
val discharge = power * orientation
|
||||
val cold = a.outsideTempCelsius?.let { it < COLD_CELSIUS }
|
||||
val low = a.socPercent?.let { it < LOW_SOC_PERCENT }
|
||||
if (cold != null) coldKnown = true
|
||||
if (cold == true && discharge < 0.0) coldRegen += -discharge * hours(a, b)
|
||||
if (discharge <= HARD_SHARE * peak) return@forEach
|
||||
when {
|
||||
cold == true || low == true -> stressedMs += ms
|
||||
cold == null || low == null -> undecidedMs += ms
|
||||
}
|
||||
}
|
||||
if (totalMs == 0L || undecidedMs > MAX_UNDECIDED_SHARE * totalMs) return null
|
||||
return DrivingStress(stressedMs / MINUTE_MS, coldRegen.takeIf { coldKnown })
|
||||
}
|
||||
|
||||
/** Per local month, over the trips that could say; a month none could say is absent. */
|
||||
fun monthly(trips: List<StoredTrip>, zone: ZoneId): List<MonthlyDrivingStress> = trips
|
||||
.mapNotNull { trip -> of(trip.samples.orEmpty())?.let { trip.summary.startedAtMs to it } }
|
||||
.groupBy { (atMs, _) -> YearMonth.from(Instant.ofEpochMilli(atMs).atZone(zone)) }
|
||||
.map { (month, list) ->
|
||||
val regen = list.mapNotNull { it.second.coldRegeneratedKwh }
|
||||
MonthlyDrivingStress(
|
||||
month = month,
|
||||
stressedMinutes = list.sumOf { it.second.stressedMinutes },
|
||||
coldRegeneratedKwh = regen.takeIf { it.isNotEmpty() }?.sum(),
|
||||
trips = list.size,
|
||||
)
|
||||
}
|
||||
.sortedBy { it.month }
|
||||
|
||||
private fun hours(a: TripSample, b: TripSample) = (b.atMs - a.atMs) / HOUR_MS
|
||||
|
||||
const val HARD_SHARE = 0.6
|
||||
const val LOW_SOC_PERCENT = 20f
|
||||
const val COLD_CELSIUS = 0f
|
||||
/** A trip that spent less than a point cannot orient its power. */
|
||||
const val MIN_SOC_DROP = 1f
|
||||
const val MIN_NET_KWH = 0.1
|
||||
/** A longer hole in the track is a pause, not a stretch of known power. */
|
||||
const val MAX_GAP_MS = 5 * 60_000L
|
||||
/** A dropped reading here and there is not a blind trip; more than this is. */
|
||||
const val MAX_UNDECIDED_SHARE = 0.1
|
||||
private const val MINUTE_MS = 60_000.0
|
||||
private const val HOUR_MS = 3_600_000.0
|
||||
}
|
||||
}
|
||||
|
||||
data class MonthlyDrivingStress(
|
||||
val month: YearMonth,
|
||||
val stressedMinutes: Double,
|
||||
val coldRegeneratedKwh: Double?,
|
||||
/** Trips that could say; the others are not counted as zero. */
|
||||
val trips: Int,
|
||||
)
|
||||
@@ -0,0 +1,384 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import com.evsuite.hardware.FirmwareInfo
|
||||
import com.evsuite.hardware.telemetry.model.SocConsumptionFitter
|
||||
import com.evsuite.hardware.telemetry.model.SocConsumptionModel
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
/** How this drive compares with what the driver's own fitted history expected of it. */
|
||||
enum class EcoBand {
|
||||
/** Below the expectation by more than the fit's own band. */
|
||||
BETTER,
|
||||
|
||||
/** Inside the band. A fit that does not know much says this often, and should. */
|
||||
TYPICAL,
|
||||
|
||||
/** Above the expectation by more than the band. */
|
||||
WORSE,
|
||||
}
|
||||
|
||||
/**
|
||||
* The one thing worth saying, chosen from the two the recorded signals can measure.
|
||||
*
|
||||
* The climate system is deliberately not here. Cabin state is passive state — an on/off flag and
|
||||
* a fan level — and turning either into kilowatts is exactly what `AGENTS.md` forbids. The cabin's
|
||||
* share is a fitted number that only exists after the trip, where `EnergyAttribution` produces it.
|
||||
*/
|
||||
enum class EcoLever {
|
||||
/**
|
||||
* Drag rises with the square of speed, and the fitted model carries that term, so the saving
|
||||
* is arithmetic on a fit this car trained rather than a rule of thumb.
|
||||
*/
|
||||
CRUISE_SPEED,
|
||||
|
||||
/**
|
||||
* Time spent accelerating or braking hard. Measured from the speed channel alone, which
|
||||
* matters: on a firmware where battery power is derived rather than measured this lever is
|
||||
* still a measurement.
|
||||
*/
|
||||
STEADINESS,
|
||||
}
|
||||
|
||||
/**
|
||||
* One piece of advice, with the number that earned it.
|
||||
*
|
||||
* Exactly one of the two number pairs is populated, decided by [lever]. There is no common
|
||||
* currency between "14 % less at 110" and "a fifth of this drive was hard acceleration", and
|
||||
* inventing one to rank them would be inventing the more important of the two figures.
|
||||
*/
|
||||
data class EcoAdvice(
|
||||
val lever: EcoLever,
|
||||
/** [EcoLever.CRUISE_SPEED]: what the fit says the slower speed costs, as a percentage saved. */
|
||||
val savingPercent: Double? = null,
|
||||
val fromSpeedKmh: Double? = null,
|
||||
val toSpeedKmh: Double? = null,
|
||||
/** [EcoLever.STEADINESS]: the share of moving time spent beyond the threshold, either sign. */
|
||||
val harshSharePercent: Double? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* What this drive is costing, against what this driver's own history expected it to cost.
|
||||
*
|
||||
* Everything here is in **percent of charge per 100 km**, never kilowatt-hours, and that is a
|
||||
* decision rather than a convenience: `EnergySnapshot.batteryPowerKw`'s sign convention is still
|
||||
* unvalidated (RI-002, CP-073), so every kWh figure in this app inherits an open question. Charge
|
||||
* and speed are the two signals CP-003 actually proved on this car, and a verdict the driver is
|
||||
* asked to act on is built on those.
|
||||
*/
|
||||
data class EcoVerdict(
|
||||
/**
|
||||
* The verdict, and — as its band — the width of the model's own `TYPICAL` zone in percent
|
||||
* per 100 km: how far [observedPercentPer100Km] would have had to move to say something
|
||||
* else. A fit that knows little carries a wide one and says `TYPICAL` for almost any drive,
|
||||
* which is the honest answer from a fit that knows little.
|
||||
*/
|
||||
val band: Provenanced<EcoBand>,
|
||||
val observedPercentPer100Km: Double? = null,
|
||||
val expectedPercentPer100Km: Double? = null,
|
||||
/** Signed: negative is below the expectation, which is the good direction. */
|
||||
val deltaPercent: Double? = null,
|
||||
val meanSpeedKmh: Double? = null,
|
||||
val advice: EcoAdvice? = null,
|
||||
) {
|
||||
/** For the diagnostic bundle: the verdict, its inputs, and any refusal, on one line each. */
|
||||
fun describe(): List<String> {
|
||||
val lines = ArrayList<String>()
|
||||
lines += "band=" + (band.value?.name ?: "unavailable(${band.reason?.name})")
|
||||
lines += "observed_percent_per_100km=${format(observedPercentPer100Km)}"
|
||||
lines += "expected_percent_per_100km=${format(expectedPercentPer100Km)}"
|
||||
lines += "delta_percent=${format(deltaPercent)}"
|
||||
lines += "mean_speed_kmh=${format(meanSpeedKmh)}"
|
||||
val advice = advice
|
||||
lines += if (advice == null) {
|
||||
"advice=none"
|
||||
} else {
|
||||
"advice=${advice.lever.name}" +
|
||||
" saving_percent=${format(advice.savingPercent)}" +
|
||||
" from_speed_kmh=${format(advice.fromSpeedKmh)}" +
|
||||
" to_speed_kmh=${format(advice.toSpeedKmh)}" +
|
||||
" harsh_share_percent=${format(advice.harshSharePercent)}"
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private fun format(value: Double?): String =
|
||||
if (value == null) "unavailable" else String.format(Locale.ROOT, "%.2f", value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The eco verdict and its one piece of advice, live on the dashboard and replayed after the trip.
|
||||
*
|
||||
* **One analyzer, two callers.** The dashboard feeds it the 1 Hz speed channel as it arrives; the
|
||||
* trip review replays a stored [TripSample] track through the same instance. That is the whole
|
||||
* reason it lives here rather than in the app: the number the driver saw at 90 km/h and the number
|
||||
* they read afterwards come out of the same code, and cannot disagree.
|
||||
*
|
||||
* **It costs nothing to run.** No new sampling, no vehicle call, no allocation per frame beyond one
|
||||
* bounded deque of speeds. The verdict is arithmetic over a trip summary the recorder already keeps
|
||||
* and a model CP-052 already trained.
|
||||
*
|
||||
* **It refuses rather than guesses.** No fit, a speed outside the envelope the fit was trained in,
|
||||
* too little distance to divide by, too small a charge drop to read against a gauge that moves in
|
||||
* whole percent — each of those is an [UnavailableReason] the rest of the app already renders, not
|
||||
* a number with a shrug attached.
|
||||
*/
|
||||
class EcoDrivingMonitor(
|
||||
private val windowMs: Long = WINDOW_MS,
|
||||
private val harshAccelerationMs2: Double = HARSH_ACCELERATION_MS2,
|
||||
private val maxSamples: Int = MAX_WINDOW_SAMPLES,
|
||||
private val maxGapMs: Long = MAX_GAP_MS,
|
||||
) {
|
||||
private data class Speed(val atMs: Long, val kmh: Double)
|
||||
|
||||
private val window = ArrayDeque<Speed>()
|
||||
|
||||
init {
|
||||
require(windowMs > 0L) { "the lever window must be positive" }
|
||||
require(harshAccelerationMs2 > 0.0) { "the acceleration threshold must be positive" }
|
||||
require(maxSamples > 1) { "a window of one sample measures nothing" }
|
||||
require(maxGapMs > 0L) { "the sampling gap must be positive" }
|
||||
}
|
||||
|
||||
/** Feed one frame. A null or negative speed is a gap in the window, never a zero. */
|
||||
fun add(atMs: Long, speedKmh: Float?) {
|
||||
if (window.lastOrNull()?.let { atMs <= it.atMs } == true) reset()
|
||||
val speed = speedKmh?.toDouble()?.takeIf { it.isFinite() && it >= 0.0 }
|
||||
if (speed != null) window.addLast(Speed(atMs, speed))
|
||||
while (window.size > maxSamples) window.removeFirst()
|
||||
while (window.size > 1 && atMs - window.first().atMs > windowMs) window.removeFirst()
|
||||
}
|
||||
|
||||
fun reset() = window.clear()
|
||||
|
||||
/** Replays a stored track, so a finished trip is judged by the same code that judged it live. */
|
||||
fun replay(samples: List<TripSample>): EcoDrivingMonitor {
|
||||
reset()
|
||||
samples.forEach { add(it.atMs, it.speedKmh) }
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* The share of moving time spent accelerating or braking hard, whatever the cruise lever said.
|
||||
*
|
||||
* Live, the two levers compete for one line and the cruise lever wins when it has a measured
|
||||
* saving. A trip review has no such contest: the motorway question is answered there by
|
||||
* `SpeedWhatIfCalculator` from the trip's own intervals, and this is the other half.
|
||||
*/
|
||||
fun steadiness(): EcoAdvice? = steadinessAdvice()
|
||||
|
||||
/**
|
||||
* @param trip the trip so far, live, or the finished one. Its charge drop and its distance are
|
||||
* what the verdict is measured from.
|
||||
* @param outsideTempCelsius the fit's second input; without it the model cannot be asked.
|
||||
*/
|
||||
fun verdict(
|
||||
model: SocConsumptionModel?,
|
||||
trip: EnergyTripSummary?,
|
||||
outsideTempCelsius: Float?,
|
||||
generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(),
|
||||
): EcoVerdict {
|
||||
val advice = advice(model, outsideTempCelsius)
|
||||
if (model == null) return refuse(UnavailableReason.MODEL_NOT_TRAINED, advice)
|
||||
|
||||
// A distance the current speed conversion did not produce may carry a 3.6x error, and a
|
||||
// verdict built on it would be wrong by the same factor without saying so.
|
||||
val distanceKm = trip?.modellableDistanceKm(generation)
|
||||
val start = trip?.startSocPercent?.toDouble()
|
||||
val end = trip?.endSocPercent?.toDouble()
|
||||
val hours = trip?.durationMs?.let { it / MILLIS_PER_HOUR }
|
||||
if (distanceKm == null || distanceKm < SocConsumptionFitter.MIN_SEGMENT_KM ||
|
||||
start == null || end == null || hours == null || hours <= 0.0
|
||||
) {
|
||||
return refuse(UnavailableReason.INSUFFICIENT_SAMPLES, advice)
|
||||
}
|
||||
// The gauge moves in whole percent. Below the fitter's own floor the quantisation is
|
||||
// larger than the thing being measured.
|
||||
val drop = start - end
|
||||
if (drop < SocConsumptionFitter.MIN_SOC_DROP_PERCENT) {
|
||||
return refuse(UnavailableReason.INSUFFICIENT_SAMPLES, advice)
|
||||
}
|
||||
|
||||
val meanSpeedKmh = distanceKm / hours
|
||||
val temp = outsideTempCelsius?.toDouble()
|
||||
?: return refuse(UnavailableReason.SIGNAL_ABSENT, advice, meanSpeedKmh)
|
||||
val expected = model.predict(meanSpeedKmh, temp)
|
||||
val expectedValue = expected.value
|
||||
?: return refuse(
|
||||
expected.reason ?: UnavailableReason.MODEL_NOT_TRAINED, advice, meanSpeedKmh
|
||||
)
|
||||
|
||||
val observed = drop / distanceKm * PER_100_KM
|
||||
// Compared against the fit's *own* band rather than a threshold invented here: a fit with
|
||||
// a wide residual says TYPICAL more often, which is the correct behaviour for a fit that
|
||||
// does not know much yet. Half of it, though, not all: the prediction band is 1.96
|
||||
// residuals wide, so TYPICAL covered 95 % of drives and the verdict almost never said
|
||||
// anything else. One residual either side leaves about a third of drives outside it.
|
||||
val band = (expected.uncertainty ?: 0.0) * VERDICT_BAND_SHARE
|
||||
val verdict = when {
|
||||
observed < expectedValue - band -> EcoBand.BETTER
|
||||
observed > expectedValue + band -> EcoBand.WORSE
|
||||
else -> EcoBand.TYPICAL
|
||||
}
|
||||
return EcoVerdict(
|
||||
band = Provenanced.estimated(verdict, band),
|
||||
observedPercentPer100Km = observed,
|
||||
expectedPercentPer100Km = expectedValue,
|
||||
deltaPercent = (observed - expectedValue) / expectedValue * PERCENT,
|
||||
meanSpeedKmh = meanSpeedKmh,
|
||||
advice = advice,
|
||||
)
|
||||
}
|
||||
|
||||
private fun refuse(
|
||||
reason: UnavailableReason,
|
||||
advice: EcoAdvice?,
|
||||
meanSpeedKmh: Double? = null,
|
||||
) = EcoVerdict(
|
||||
band = Provenanced.unavailable(reason),
|
||||
meanSpeedKmh = meanSpeedKmh,
|
||||
advice = advice,
|
||||
)
|
||||
|
||||
/**
|
||||
* At most one lever, and by precedence rather than by a ranking that would have to invent a
|
||||
* comparison. The two are near-disjoint in practice anyway: cruise speed cannot fire below
|
||||
* [CRUISE_MIN_KMH], and a drive spent above it is not a drive spent accelerating.
|
||||
*/
|
||||
private fun advice(model: SocConsumptionModel?, outsideTempCelsius: Float?): EcoAdvice? =
|
||||
cruiseAdvice(model, outsideTempCelsius) ?: steadinessAdvice()
|
||||
|
||||
private fun cruiseAdvice(
|
||||
model: SocConsumptionModel?,
|
||||
outsideTempCelsius: Float?,
|
||||
): EcoAdvice? {
|
||||
if (model == null) return null
|
||||
val temp = outsideTempCelsius?.toDouble() ?: return null
|
||||
val from = windowMeanSpeedKmh() ?: return null
|
||||
if (from < CRUISE_MIN_KMH) return null
|
||||
val to = from - CRUISE_STEP_KMH
|
||||
val at = model.predict(from, temp).value ?: return null
|
||||
val slower = model.predict(to, temp).value ?: return null
|
||||
val saving = (at - slower) / at * PERCENT
|
||||
if (!saving.isFinite() || saving < MIN_ADVICE_SAVING_PERCENT) return null
|
||||
return EcoAdvice(
|
||||
lever = EcoLever.CRUISE_SPEED,
|
||||
savingPercent = saving,
|
||||
fromSpeedKmh = from,
|
||||
toSpeedKmh = to,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The share of moving time spent accelerating or braking hard, below the advice threshold
|
||||
* included.
|
||||
*
|
||||
* Braking counts because every hard stop throws away what regeneration could not take back,
|
||||
* and because the trip review (`DrivingStyle` in EVChargePilot) has always counted it: a live
|
||||
* gauge that saw only half the habit read lower than the summary of the same drive.
|
||||
*
|
||||
* Null only while the window holds less than [MIN_LEVER_WINDOW_MS] of movement, so a caller
|
||||
* can tell "not measured yet" from "measured, and calm" — the two read the same through
|
||||
* [steadiness], which answers only when there is something to say.
|
||||
*/
|
||||
fun harshSharePercent(): Double? {
|
||||
var movingMs = 0L
|
||||
var harshMs = 0L
|
||||
window.zipWithNext { previous, current ->
|
||||
val deltaMs = current.atMs - previous.atMs
|
||||
// Beyond the gap this app allows itself to integrate over, the missing minutes are
|
||||
// unknown motion. Reading them as one enormous step would invent an acceleration
|
||||
// out of the app having been asleep.
|
||||
if (deltaMs !in 1..maxGapMs) return@zipWithNext
|
||||
if (current.kmh <= 0.0 && previous.kmh <= 0.0) return@zipWithNext
|
||||
movingMs += deltaMs
|
||||
val acceleration = (current.kmh - previous.kmh) / KMH_PER_MS2 / (deltaMs / 1000.0)
|
||||
if (abs(acceleration) >= harshAccelerationMs2) harshMs += deltaMs
|
||||
}
|
||||
if (movingMs < MIN_LEVER_WINDOW_MS) return null
|
||||
return harshMs.toDouble() / movingMs * PERCENT
|
||||
}
|
||||
|
||||
private fun steadinessAdvice(): EcoAdvice? {
|
||||
val share = harshSharePercent() ?: return null
|
||||
if (share < MIN_HARSH_SHARE_PERCENT) return null
|
||||
return EcoAdvice(lever = EcoLever.STEADINESS, harshSharePercent = share)
|
||||
}
|
||||
|
||||
private fun windowMeanSpeedKmh(): Double? {
|
||||
if (window.size < 2) return null
|
||||
var weightedMs = 0L
|
||||
var distanceKmh = 0.0
|
||||
window.zipWithNext { previous, current ->
|
||||
val deltaMs = current.atMs - previous.atMs
|
||||
if (deltaMs !in 1..maxGapMs) return@zipWithNext
|
||||
weightedMs += deltaMs
|
||||
distanceKmh += (previous.kmh + current.kmh) / 2.0 * deltaMs
|
||||
}
|
||||
if (weightedMs < MIN_LEVER_WINDOW_MS) return null
|
||||
return distanceKmh / weightedMs
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The whole of a finished drive, rather than the last three minutes of a live one.
|
||||
*
|
||||
* A review that judged steadiness on the final window would grade the driver on their
|
||||
* arrival, and the bound that keeps the live window cheap has no purpose over a track
|
||||
* that is already on the disk.
|
||||
*/
|
||||
fun forReplay() = EcoDrivingMonitor(
|
||||
windowMs = Long.MAX_VALUE,
|
||||
maxSamples = Int.MAX_VALUE,
|
||||
maxGapMs = REPLAY_MAX_GAP_MS,
|
||||
)
|
||||
|
||||
/** The share of the prediction band that counts as TYPICAL: about one residual. */
|
||||
const val VERDICT_BAND_SHARE = 0.5
|
||||
|
||||
/** Long enough that a single roundabout is not a driving style. */
|
||||
const val WINDOW_MS = 180_000L
|
||||
|
||||
/**
|
||||
* Brisk rather than emergency. Eco-driving practice puts the useful line around here, and
|
||||
* the number is a calibration knob rather than physics: a heavier car, a different tyre or
|
||||
* a driver who simply disagrees moves it, and nothing else in this class assumes its value.
|
||||
*
|
||||
* 1.5 m/s² was too low on the car: ordinary town drives counted 7 to 16 hard stops every
|
||||
* 10 km (bundle of 2026-10-18), and regeneration on High alone comes close to that line
|
||||
* with no brake pedal at all. 2.5 sits above lift-off, where the brakes do the work.
|
||||
*/
|
||||
const val HARSH_ACCELERATION_MS2 = 2.5
|
||||
|
||||
/** Below this the levers are measuring a traffic light, not a drive. */
|
||||
const val MIN_LEVER_WINDOW_MS = 60_000L
|
||||
|
||||
/** Under this share, saying anything would be nagging about noise. */
|
||||
const val MIN_HARSH_SHARE_PERCENT = 12.0
|
||||
|
||||
/** Motorway, where the square term is worth more than everything else on this list. */
|
||||
const val CRUISE_MIN_KMH = 95.0
|
||||
|
||||
/** The step the advice proposes: large enough to matter, small enough to be plausible. */
|
||||
const val CRUISE_STEP_KMH = 10.0
|
||||
|
||||
/** A saving smaller than this is not worth a line the driver has to read. */
|
||||
const val MIN_ADVICE_SAVING_PERCENT = 3.0
|
||||
|
||||
/** The app's own integration rule: past five seconds, the motion in between is unknown. */
|
||||
const val MAX_GAP_MS = 5_000L
|
||||
|
||||
/** Stored samples cross the five-second boundary on the next live telemetry frame. */
|
||||
private const val REPLAY_MAX_GAP_MS = TripSampleTrack.DEFAULT_INTERVAL_MS * 2
|
||||
|
||||
/** Three minutes at 1 Hz, with room for a faster caller. */
|
||||
const val MAX_WINDOW_SAMPLES = 1_024
|
||||
|
||||
private const val MILLIS_PER_HOUR = 3_600_000.0
|
||||
private const val PER_100_KM = 100.0
|
||||
private const val PERCENT = 100.0
|
||||
|
||||
/** 1 m/s² is 3.6 km/h per second. */
|
||||
private const val KMH_PER_MS2 = 3.6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A trip out of 100, and the three measured parts it is made of.
|
||||
*
|
||||
* Every part is a measurement this car is known to give: hard braking and hard acceleration
|
||||
* from the speed channel, counted at 1 Hz by [EnergyTripAccumulator], and the charge against
|
||||
* the driver's own fitted expectation from [EcoVerdict]. A part that could not be measured is
|
||||
* null and the total is taken over the others; a trip where none could be measured has no score
|
||||
* at all, never a default one.
|
||||
*
|
||||
* The scales are calibration knobs rather than physics. They are set so a calm town drive at
|
||||
* the driver's usual consumption lands in the sixties and the eighties need effort: a score that
|
||||
* starts near 100 has nothing left to reward.
|
||||
*/
|
||||
data class EcoScore(
|
||||
val total: Int,
|
||||
/** Hard braking per 10 km: braking hard throws away what regeneration could not take back. */
|
||||
val anticipation: Int?,
|
||||
/** Share of moving time beyond the hard-acceleration threshold, either sign. */
|
||||
val smoothness: Int?,
|
||||
/** Charge against the driver's own expectation at this speed and temperature. */
|
||||
val efficiency: Int?,
|
||||
) {
|
||||
companion object {
|
||||
fun of(summary: EnergyTripSummary, verdict: EcoVerdict?): EcoScore? {
|
||||
val distanceKm = summary.recordedDistanceKm
|
||||
val anticipation = summary.harshBrakings?.let { brakings ->
|
||||
distanceKm?.takeIf { it >= MIN_DISTANCE_KM }?.let { km ->
|
||||
scale(brakings / km * PER_10_KM, best = 0.0, worst = BRAKINGS_PER_10_KM_AT_ZERO)
|
||||
}
|
||||
}
|
||||
val smoothness = summary.harshMs?.let { harsh ->
|
||||
summary.movingMs?.takeIf { it >= MIN_MOVING_MS }?.let { moving ->
|
||||
scale(harsh.toDouble() / moving * PERCENT, best = 0.0, worst = HARSH_SHARE_AT_ZERO)
|
||||
}
|
||||
}
|
||||
val efficiency = verdict?.takeIf { it.band.value != null }?.deltaPercent?.let {
|
||||
scale(it, best = DELTA_AT_FULL, worst = DELTA_AT_ZERO)
|
||||
}
|
||||
val parts = listOf(
|
||||
anticipation to ANTICIPATION_WEIGHT,
|
||||
smoothness to SMOOTHNESS_WEIGHT,
|
||||
efficiency to EFFICIENCY_WEIGHT,
|
||||
).filter { it.first != null }
|
||||
if (parts.isEmpty()) return null
|
||||
val total = parts.sumOf { it.first!! * it.second } / parts.sumOf { it.second }
|
||||
return EcoScore(total.roundToInt(), anticipation, smoothness, efficiency)
|
||||
}
|
||||
|
||||
/** 100 at [best], 0 at [worst], straight in between and clamped outside. */
|
||||
private fun scale(value: Double, best: Double, worst: Double): Int? {
|
||||
if (!value.isFinite()) return null
|
||||
val share = ((worst - value) / (worst - best)).coerceIn(0.0, 1.0)
|
||||
return (share * PERCENT).roundToInt()
|
||||
}
|
||||
|
||||
/** Shorter than the fitter's own segment, a single junction would be a habit. */
|
||||
const val MIN_DISTANCE_KM = 2.0
|
||||
const val MIN_MOVING_MS = 60_000L
|
||||
|
||||
/** Six hard stops every 10 km is a drive with no anticipation left to measure. */
|
||||
const val BRAKINGS_PER_10_KM_AT_ZERO = 6.0
|
||||
|
||||
/** A quarter of moving time spent hard is the bottom of the scale. */
|
||||
const val HARSH_SHARE_AT_ZERO = 25.0
|
||||
|
||||
/** 15 % under the driver's own expectation is full marks; 25 % over is none. */
|
||||
const val DELTA_AT_FULL = -15.0
|
||||
const val DELTA_AT_ZERO = 25.0
|
||||
|
||||
const val ANTICIPATION_WEIGHT = 0.35
|
||||
const val SMOOTHNESS_WEIGHT = 0.25
|
||||
const val EFFICIENCY_WEIGHT = 0.40
|
||||
|
||||
private const val PER_10_KM = 10.0
|
||||
private const val PERCENT = 100.0
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import com.evsuite.hardware.model.DriveMode
|
||||
import com.evsuite.hardware.model.RegenLevel
|
||||
|
||||
import com.evsuite.hardware.FirmwareInfo
|
||||
|
||||
/** One immutable, nullable, read-only view of the vehicle's energy state. */
|
||||
@@ -33,6 +36,18 @@ data class EnergySnapshot(
|
||||
val tirePressures: TirePressureSnapshot,
|
||||
val batteryVoltageV: Float? = null,
|
||||
val batteryCurrentA: Float? = null,
|
||||
/**
|
||||
* CP-086. The widest-open window, 0 closed to 100 open; null where the car does not answer,
|
||||
* never 0 — a closed window and an unread one are different facts to the attribution.
|
||||
*/
|
||||
val widestWindowPercent: Int? = null,
|
||||
/**
|
||||
* The vendor's own drive mode and regen codes, unmapped: [DriveMode.ofRaw] and
|
||||
* [RegenLevel.ofRaw] decode them, and a code neither knows stays a code rather than
|
||||
* becoming NORMAL or MEDIUM. Null where the car did not answer.
|
||||
*/
|
||||
val driveModeRaw: Int? = null,
|
||||
val regenLevelRaw: Int? = null,
|
||||
) {
|
||||
val hasVehicleData: Boolean
|
||||
get() = socPercent != null || rangeKm != null || speedKmh != null ||
|
||||
@@ -40,7 +55,8 @@ data class EnergySnapshot(
|
||||
batteryTempCelsius != null || batteryEnergyKwh != null || batteryCapacityKwh != null ||
|
||||
odometerKm != null || chargePortConnected != null || chargingStatus != null ||
|
||||
vehicleConsumedKwh != null || vehicleRegeneratedKwh != null ||
|
||||
parked != null || climate.hasData || tirePressures.hasData
|
||||
parked != null || climate.hasData || tirePressures.hasData ||
|
||||
widestWindowPercent != null || driveModeRaw != null || regenLevelRaw != null
|
||||
}
|
||||
|
||||
data class TirePressureSnapshot(
|
||||
|
||||
+32
-6
@@ -6,6 +6,7 @@ import com.evsuite.hardware.FirmwareInfo
|
||||
import com.evsuite.hardware.saic.SaicCharging
|
||||
import com.evsuite.hardware.saic.SaicClimate
|
||||
import com.evsuite.hardware.saic.SaicHub
|
||||
import com.evsuite.hardware.saic.SaicVehicleControl
|
||||
|
||||
/** Injectable signal seam: production uses [EvHardwareEnergySignalSource], tests use a fake. */
|
||||
interface EnergySignalSource {
|
||||
@@ -28,6 +29,11 @@ interface EnergySignalSource {
|
||||
fun parked(): Boolean?
|
||||
fun batteryVoltageV(): Float? = null
|
||||
fun batteryCurrentA(): Float? = null
|
||||
/** Widest window opening in percent; null where the firmware has no read. */
|
||||
fun widestWindowPercent(): Int? = null
|
||||
/** Raw vendor codes; null where the firmware has no read. */
|
||||
fun driveModeRaw(): Int? = null
|
||||
fun regenLevelRaw(): Int? = null
|
||||
fun climate(): ClimateSnapshot
|
||||
fun tirePressures(): TirePressureSnapshot
|
||||
/** Probe-only values excluded from [EnergySnapshot] and normal consumers. */
|
||||
@@ -70,6 +76,9 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
|
||||
override fun parked() = EVHardware.isVehicleInPark()
|
||||
override fun batteryVoltageV() = EVHardware.getVendorBatteryVoltageV()
|
||||
override fun batteryCurrentA() = EVHardware.getVendorBatteryCurrentA()
|
||||
override fun widestWindowPercent() = SaicVehicleControl.widestWindowPercent()
|
||||
override fun driveModeRaw() = EVHardware.getDriveModeRaw()
|
||||
override fun regenLevelRaw() = EVHardware.getRegenLevelRaw()
|
||||
override fun tirePressures() = TirePressureSnapshot(
|
||||
frontLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_LEFT),
|
||||
frontRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_RIGHT),
|
||||
@@ -80,7 +89,23 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
|
||||
TelemetryEvidenceProbe(
|
||||
TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH,
|
||||
EVHardware.probeCurrentBatteryCapacityWh()?.toDouble(),
|
||||
)
|
||||
),
|
||||
TelemetryEvidenceProbe(
|
||||
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_CHARGE_STATUS,
|
||||
EVHardware.probeVendorChargeStatus()?.toDouble(),
|
||||
),
|
||||
TelemetryEvidenceProbe(
|
||||
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_PLUG_AC,
|
||||
EVHardware.probeVendorPlugAc()?.toDouble(),
|
||||
),
|
||||
TelemetryEvidenceProbe(
|
||||
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_PLUG_DC,
|
||||
EVHardware.probeVendorPlugDc()?.toDouble(),
|
||||
),
|
||||
TelemetryEvidenceProbe(
|
||||
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_ENERGY_SINCE_CHARGE_KWH,
|
||||
EVHardware.probeVendorEnergySinceChargeKwh()?.toDouble(),
|
||||
),
|
||||
)
|
||||
override fun climate() = ClimateSnapshot(
|
||||
powerOn = SaicClimate.powerOn(),
|
||||
@@ -94,11 +119,9 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
|
||||
?: EVHardware.getTemperatureSetCelsius(),
|
||||
// The passenger setpoint is its own signal only when the car publishes one: the vendor
|
||||
// service reads it with a default of 68, which its own converter maps to -1, and -1 is
|
||||
// dropped upstream. With a single zone the driver's setpoint is what both seats get,
|
||||
// so that is what the passenger reads — the same number, because it is the same air.
|
||||
passengerTargetCelsius = SaicClimate.passengerTemp()?.toFloat()
|
||||
?: SaicClimate.driverTemp()?.toFloat()
|
||||
?: EVHardware.getTemperatureSetCelsius(),
|
||||
// dropped upstream. Null then, not the driver's number: a screen that shows both seats
|
||||
// the same air says so itself, and evidence captured here must not call a copy a reading.
|
||||
passengerTargetCelsius = SaicClimate.passengerTemp()?.toFloat(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,6 +151,9 @@ class EnergyTelemetryReader(private val source: EnergySignalSource) {
|
||||
tirePressures = source.tirePressures(),
|
||||
batteryVoltageV = source.batteryVoltageV()?.takeIf { it.isFinite() },
|
||||
batteryCurrentA = source.batteryCurrentA()?.takeIf { it.isFinite() },
|
||||
widestWindowPercent = source.widestWindowPercent()?.takeIf { it in 0..100 },
|
||||
driveModeRaw = source.driveModeRaw(),
|
||||
regenLevelRaw = source.regenLevelRaw(),
|
||||
)
|
||||
|
||||
/** Reads CP-004 candidates only when an unstable evidence capture explicitly requests them. */
|
||||
|
||||
@@ -32,6 +32,20 @@ data class EnergyTripSummary(
|
||||
* train on it. See [VehicleSpeedEvidence].
|
||||
*/
|
||||
val speedEvidence: VehicleSpeedEvidence? = null,
|
||||
/**
|
||||
* How the trip was driven, counted at the live sampling rate over the whole trip.
|
||||
*
|
||||
* The stored track is one sample per five seconds, and a two-second hard stop averaged over
|
||||
* five reads as a gentle one: replayed, the same drive came out calmer than it was. Null on
|
||||
* trips recorded before these were counted, which a reader treats as "not counted", never 0.
|
||||
*/
|
||||
val movingMs: Long? = null,
|
||||
val harshMs: Long? = null,
|
||||
val harshAccelerations: Int? = null,
|
||||
val harshBrakings: Int? = null,
|
||||
/** The vendor drive mode and regen codes held for the longest share of the trip. */
|
||||
val driveModeRaw: Int? = null,
|
||||
val regenLevelRaw: Int? = null,
|
||||
) {
|
||||
/**
|
||||
* The distance, only when the conversion behind it is the one believed correct today.
|
||||
@@ -75,6 +89,13 @@ class EnergyTripAccumulator(
|
||||
private var hasPowerInterval = false
|
||||
private var recordedMs = 0L
|
||||
private var latestSocPercent = startSocPercent
|
||||
private var movingMs = 0L
|
||||
private var harshMs = 0L
|
||||
private var harshAccelerations = 0
|
||||
private var harshBrakings = 0
|
||||
private var harshSign = 0
|
||||
private val modeMs = HashMap<Int, Long>()
|
||||
private val regenMs = HashMap<Int, Long>()
|
||||
|
||||
fun add(sample: EnergySnapshot) {
|
||||
track.add(sample)
|
||||
@@ -90,6 +111,7 @@ class EnergyTripAccumulator(
|
||||
hasSpeedInterval = true
|
||||
distanceKm += ((previous.speedKmh + sample.speedKmh) / 2.0) * hours
|
||||
}
|
||||
countStyle(previous, sample, gapMs)
|
||||
if (previous.batteryPowerKw != null && sample.batteryPowerKw != null) {
|
||||
hasPowerInterval = true
|
||||
val intervalKwh =
|
||||
@@ -99,10 +121,36 @@ class EnergyTripAccumulator(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One interval of driving style. A hard stretch counts once however many seconds it lasts:
|
||||
* a driver asked to brake less hard three times wants three to be the number, not eleven.
|
||||
*/
|
||||
private fun countStyle(previous: EnergySnapshot, sample: EnergySnapshot, gapMs: Long) {
|
||||
sample.driveModeRaw?.let { modeMs.merge(it, gapMs, Long::plus) }
|
||||
sample.regenLevelRaw?.let { regenMs.merge(it, gapMs, Long::plus) }
|
||||
val from = previous.speedKmh ?: return
|
||||
val to = sample.speedKmh ?: return
|
||||
if (from <= 0f && to <= 0f) {
|
||||
harshSign = 0
|
||||
return
|
||||
}
|
||||
movingMs += gapMs
|
||||
val acceleration = (to - from) / KMH_PER_MS2 / (gapMs / 1000.0)
|
||||
val sign = when {
|
||||
acceleration >= EcoDrivingMonitor.HARSH_ACCELERATION_MS2 -> 1
|
||||
acceleration <= -EcoDrivingMonitor.HARSH_ACCELERATION_MS2 -> -1
|
||||
else -> 0
|
||||
}
|
||||
if (sign != 0) harshMs += gapMs
|
||||
if (sign == 1 && harshSign != 1) harshAccelerations++
|
||||
if (sign == -1 && harshSign != -1) harshBrakings++
|
||||
harshSign = sign
|
||||
}
|
||||
|
||||
fun samples(): List<TripSample> = track.samples()
|
||||
|
||||
fun snapshot(nowMs: Long) = EnergyTripSummary(
|
||||
startedAtMs = startedAtMs,
|
||||
startedAtMs = startedAtMs(nowMs),
|
||||
endedAtMs = nowMs,
|
||||
durationMs = recordedMs,
|
||||
distanceKm = distanceKm,
|
||||
@@ -113,9 +161,30 @@ class EnergyTripAccumulator(
|
||||
distanceAvailable = hasSpeedInterval,
|
||||
speedEvidence = speedEvidence.takeIf { hasSpeedInterval },
|
||||
batteryPowerEvidence = batteryPowerEvidence.takeIf { hasPowerInterval },
|
||||
movingMs = movingMs.takeIf { hasSpeedInterval },
|
||||
harshMs = harshMs.takeIf { hasSpeedInterval },
|
||||
harshAccelerations = harshAccelerations.takeIf { hasSpeedInterval },
|
||||
harshBrakings = harshBrakings.takeIf { hasSpeedInterval },
|
||||
driveModeRaw = modeMs.maxByOrNull { it.value }?.key,
|
||||
regenLevelRaw = regenMs.maxByOrNull { it.value }?.key,
|
||||
)
|
||||
|
||||
private companion object { const val MAX_SAMPLE_GAP_MS = 5_000L }
|
||||
/**
|
||||
* A trip that started before the head unit's clock was set (it boots on its factory date and
|
||||
* syncs later) would sort years back. Once the clock is right, its start is the end minus the
|
||||
* time driven; the jump itself is a gap [add] already refuses to count.
|
||||
*/
|
||||
private fun startedAtMs(nowMs: Long): Long =
|
||||
if (startedAtMs < BatteryLedgerRecorder.MIN_PLAUSIBLE_CLOCK_MS &&
|
||||
nowMs >= BatteryLedgerRecorder.MIN_PLAUSIBLE_CLOCK_MS
|
||||
) nowMs - recordedMs else startedAtMs
|
||||
|
||||
private companion object {
|
||||
const val MAX_SAMPLE_GAP_MS = 5_000L
|
||||
|
||||
/** 1 m/s² is 3.6 km/h per second. */
|
||||
const val KMH_PER_MS2 = 3.6
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-local recording state shared by any energy dashboard in the same app process. */
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ data class TripHistoryFile(
|
||||
class EnergyTripHistoryStore(
|
||||
private val target: File,
|
||||
private val maxTrips: Int = 200,
|
||||
private val maxBytes: Int = 512 * 1024,
|
||||
private val maxBytes: Int = 2 * 1024 * 1024,
|
||||
private val gson: Gson = Gson(),
|
||||
) {
|
||||
/** Trips with whatever tracks survived eviction, newest first. */
|
||||
|
||||
@@ -104,6 +104,7 @@ class TelemetryEvidenceRecorder {
|
||||
record(CHARGING_STATUS, snapshot.chargingStatus?.toDouble(), at)
|
||||
record(CHARGE_PORT_CONNECTED, snapshot.chargePortConnected, at)
|
||||
record(PARKED, snapshot.parked, at)
|
||||
record(WINDOWS_WIDEST_PERCENT, snapshot.widestWindowPercent?.toDouble(), at)
|
||||
|
||||
val climate = snapshot.climate
|
||||
record(CLIMATE_POWER_ON, climate.powerOn, at)
|
||||
@@ -250,6 +251,7 @@ class TelemetryEvidenceRecorder {
|
||||
const val CHARGING_STATUS = "chargingStatus"
|
||||
const val CHARGE_PORT_CONNECTED = "chargePortConnected"
|
||||
const val PARKED = "parked"
|
||||
const val WINDOWS_WIDEST_PERCENT = "windows.widestPercent"
|
||||
const val CLIMATE_POWER_ON = "climate.powerOn"
|
||||
const val CLIMATE_AC_ON = "climate.acOn"
|
||||
const val CLIMATE_AUTO_ON = "climate.autoOn"
|
||||
@@ -261,6 +263,10 @@ class TelemetryEvidenceRecorder {
|
||||
const val CLIMATE_PASSENGER_TARGET_C = "climate.passengerTargetCelsius"
|
||||
const val CANDIDATE_CURRENT_BATTERY_CAPACITY_WH =
|
||||
"candidate.evCurrentBatteryCapacityWh"
|
||||
const val CANDIDATE_VENDOR_CHARGE_STATUS = "candidate.vendorChargeStatus"
|
||||
const val CANDIDATE_VENDOR_PLUG_AC = "candidate.vendorPlugAc"
|
||||
const val CANDIDATE_VENDOR_PLUG_DC = "candidate.vendorPlugDc"
|
||||
const val CANDIDATE_VENDOR_ENERGY_SINCE_CHARGE_KWH = "candidate.vendorEnergySinceChargeKwh"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ class TripDetector {
|
||||
transition(State.RECORDING, timestampMs)
|
||||
Result(state)
|
||||
}
|
||||
elapsed(timestampMs) >= STOP_DEBOUNCE_MS && endConfirmed(snapshot) -> {
|
||||
elapsed(timestampMs) >= STOP_DEBOUNCE_MS && endConfirmed(snapshot) ||
|
||||
elapsed(timestampMs) >= PARKED_STOP_DEBOUNCE_MS && endObserved(snapshot) -> {
|
||||
transition(State.IDLE, timestampMs)
|
||||
Result(state, Event.STOP)
|
||||
}
|
||||
@@ -117,9 +118,13 @@ class TripDetector {
|
||||
|
||||
private fun endConfirmed(snapshot: EnergySnapshot): Boolean {
|
||||
val confirmationAvailable = snapshot.parked != null || snapshot.chargePortConnected != null
|
||||
return !confirmationAvailable || snapshot.parked == true || snapshot.chargePortConnected == true
|
||||
return !confirmationAvailable || endObserved(snapshot)
|
||||
}
|
||||
|
||||
/** The car said so itself: a gear in P or a cable in the port is not a traffic stop. */
|
||||
private fun endObserved(snapshot: EnergySnapshot): Boolean =
|
||||
snapshot.parked == true || snapshot.chargePortConnected == true
|
||||
|
||||
companion object {
|
||||
/** Ignores parking creep and noisy zero readings; 5 km/h is unambiguous road motion. */
|
||||
const val START_SPEED_KMH = 5.0
|
||||
@@ -133,6 +138,13 @@ class TripDetector {
|
||||
/** Longer than the ticket's 90-second traffic stop while still practical at arrival. */
|
||||
const val STOP_DEBOUNCE_MS = 120_000L
|
||||
|
||||
/**
|
||||
* The SWI68 head unit powers off about twelve seconds after P (2026-10-16: P at
|
||||
* 12:13:54, off at 12:14:06), so the two-minute timer never ran out and every
|
||||
* automatic trip died with the process. A confirmed P needs no traffic allowance.
|
||||
*/
|
||||
const val PARKED_STOP_DEBOUNCE_MS = 2_000L
|
||||
|
||||
/** Five seconds tolerates scheduler jitter but cannot bridge a meaningful telemetry hole. */
|
||||
const val MAX_OBSERVATION_GAP_MS = 5_000L
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ data class TripSample(
|
||||
val climatePowerOn: Boolean?,
|
||||
val climateAcOn: Boolean?,
|
||||
val climateFanLevel: Int?,
|
||||
/** CP-086. Absent from trips stored before it, which Gson reads back as null: unknown. */
|
||||
val widestWindowPercent: Int? = null,
|
||||
/**
|
||||
* Raw vendor drive mode and regen codes, as [EnergySnapshot] carries them. Ints rather than
|
||||
* the enums: Gson writes an enum by its constant name, which a release build may rename.
|
||||
*/
|
||||
val driveModeRaw: Int? = null,
|
||||
val regenLevelRaw: Int? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun of(snapshot: EnergySnapshot) = TripSample(
|
||||
@@ -37,6 +45,9 @@ data class TripSample(
|
||||
climatePowerOn = snapshot.climate.powerOn,
|
||||
climateAcOn = snapshot.climate.acOn,
|
||||
climateFanLevel = snapshot.climate.fanLevel,
|
||||
widestWindowPercent = snapshot.widestWindowPercent,
|
||||
driveModeRaw = snapshot.driveModeRaw,
|
||||
regenLevelRaw = snapshot.regenLevelRaw,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -7,7 +7,14 @@ import com.evsuite.hardware.telemetry.UnavailableReason
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
enum class ResidualContext { CLIMATE_ACTIVE, CLIMATE_INACTIVE, CLIMATE_UNKNOWN }
|
||||
/**
|
||||
* Which auxiliary load an interval's residual may belong to.
|
||||
*
|
||||
* CP-086: [WINDOWS_OPEN] takes an interval with a window open at either end out of the climate
|
||||
* groups, so the drag of open glass never reads as cabin energy. Its residual may still carry
|
||||
* the climate system if that ran at the same time; its average fan level says whether it did.
|
||||
*/
|
||||
enum class ResidualContext { CLIMATE_ACTIVE, CLIMATE_INACTIVE, CLIMATE_UNKNOWN, WINDOWS_OPEN }
|
||||
|
||||
enum class ResidualFinding {
|
||||
DISTINGUISHABLE,
|
||||
@@ -115,7 +122,11 @@ object EnergyAttributionCalculator {
|
||||
val modelled = predictedConsumption * distanceKm / 100.0
|
||||
val uncertainty = predictedUncertainty * distanceKm / 100.0
|
||||
val measured = (max(0.0, previousPower) + max(0.0, currentPower)) / 2.0 * hours
|
||||
val context = climateContext(previous, current)
|
||||
val context = if (windowOpen(previous) || windowOpen(current)) {
|
||||
ResidualContext.WINDOWS_OPEN
|
||||
} else {
|
||||
climateContext(previous, current)
|
||||
}
|
||||
|
||||
tractionKwh += modelled
|
||||
tractionUncertaintyKwh += uncertainty
|
||||
@@ -159,6 +170,9 @@ object EnergyAttributionCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
private fun windowOpen(sample: TripSample): Boolean =
|
||||
sample.widestWindowPercent?.let { it > 0 } == true
|
||||
|
||||
private fun climateActive(sample: TripSample): Boolean? = when {
|
||||
sample.climatePowerOn == true || sample.climateAcOn == true ||
|
||||
sample.climateFanLevel?.let { it > 0 } == true -> true
|
||||
|
||||
+81
-21
@@ -6,6 +6,7 @@ import com.evsuite.hardware.FirmwareInfo
|
||||
import com.evsuite.hardware.telemetry.StoredTrip
|
||||
import com.evsuite.hardware.telemetry.UnavailableReason
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
|
||||
sealed interface EnergyModelTrainingResult {
|
||||
@@ -40,29 +41,33 @@ class EnergyModelTrainer(
|
||||
trips: List<StoredTrip>,
|
||||
evidence: BatteryPowerEvidence,
|
||||
): EnergyModelTrainingResult {
|
||||
// Both fits are accumulated in the one pass; the temperature span picks which to solve.
|
||||
val equations = LeastSquares3()
|
||||
val narrowed = LeastSquares3(2)
|
||||
var minSpeed = Double.POSITIVE_INFINITY
|
||||
var maxSpeed = Double.NEGATIVE_INFINITY
|
||||
var minTemp = Double.POSITIVE_INFINITY
|
||||
var maxTemp = Double.NEGATIVE_INFINITY
|
||||
val count = forEachUsable(trips, evidence) { speed, temp, consumption ->
|
||||
equations.add(speed, temp, consumption)
|
||||
narrowed.add(speed, temp, consumption)
|
||||
minSpeed = minOf(minSpeed, speed)
|
||||
maxSpeed = maxOf(maxSpeed, speed)
|
||||
minTemp = minOf(minTemp, temp)
|
||||
maxTemp = maxOf(maxTemp, temp)
|
||||
}
|
||||
if (count < MIN_TRAINING_SAMPLES || maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH ||
|
||||
maxTemp - minTemp < MIN_TEMPERATURE_SPAN_CELSIUS
|
||||
) {
|
||||
if (count < MIN_TRAINING_SAMPLES || maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH) {
|
||||
return EnergyModelTrainingResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
|
||||
}
|
||||
|
||||
val coefficients = equations.solve()
|
||||
// As in SocConsumptionFitter: one kind of weather is no evidence about weather, so two
|
||||
// coefficients are fitted and the narrow envelope refuses the cold instead of guessing.
|
||||
val features = if (maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2
|
||||
val coefficients = (if (features == 3) equations else narrowed).solve()
|
||||
?: return EnergyModelTrainingResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
|
||||
val rolling = coefficients[0]
|
||||
val aero = coefficients[1]
|
||||
val thermal = coefficients[2]
|
||||
val thermal = if (features == 3) coefficients[2] else 0.0
|
||||
if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 ||
|
||||
coefficients.any { !it.isFinite() }
|
||||
) {
|
||||
@@ -93,7 +98,19 @@ class EnergyModelTrainer(
|
||||
return EnergyModelTrainingResult.Ready(model)
|
||||
}
|
||||
|
||||
private inline fun forEachUsable(
|
||||
/**
|
||||
* Calls [block] once per kilometre of driving, with its mean speed, mean temperature and
|
||||
* gross consumption, and returns how many kilometres it reported.
|
||||
*
|
||||
* A 5-second sample is not a consumption. The one taken while pulling away from a light
|
||||
* costs several times the one taken cruising, and a fit made per sample read that spread
|
||||
* as model error and refused town driving for good. Over a kilometre the accelerations
|
||||
* average into what the road actually cost, which is also what the attribution predicts.
|
||||
* Standing still is skipped without ending the window: it has no speed to be modelled at.
|
||||
* A hole in the track does end it, as in SocConsumptionFitter, so no window spans
|
||||
* kilometres nobody recorded.
|
||||
*/
|
||||
private fun forEachUsable(
|
||||
trips: List<StoredTrip>,
|
||||
evidence: BatteryPowerEvidence,
|
||||
block: (speedKmh: Double, outsideTempCelsius: Double, consumption: Double) -> Unit,
|
||||
@@ -101,22 +118,55 @@ class EnergyModelTrainer(
|
||||
var accepted = 0
|
||||
for (trip in trips) {
|
||||
if (trip.summary.batteryPowerEvidence != evidence) continue
|
||||
for (sample in trip.samples.orEmpty()) {
|
||||
val speed = sample.speedKmh?.toDouble()
|
||||
val power = sample.batteryPowerKw?.toDouble()
|
||||
val temp = sample.outsideTempCelsius?.toDouble()
|
||||
if (speed == null || power == null || temp == null ||
|
||||
!speed.isFinite() || speed !in MIN_SPEED_KMH..MAX_SPEED_KMH ||
|
||||
!power.isFinite() || power <= 0.0 ||
|
||||
!temp.isFinite() || temp !in MIN_TEMP_CELSIUS..MAX_TEMP_CELSIUS
|
||||
val samples = trip.samples.orEmpty()
|
||||
var km = 0.0
|
||||
var hours = 0.0
|
||||
var kwh = 0.0
|
||||
var tempSum = 0.0
|
||||
var tempCount = 0
|
||||
for (index in 1 until samples.size) {
|
||||
val previous = samples[index - 1]
|
||||
val current = samples[index]
|
||||
val durationMs = current.atMs - previous.atMs
|
||||
val speed = mean(previous.speedKmh, current.speedKmh)
|
||||
val previousPower = previous.batteryPowerKw?.toDouble()
|
||||
val currentPower = current.batteryPowerKw?.toDouble()
|
||||
val temp = mean(previous.outsideTempCelsius, current.outsideTempCelsius)
|
||||
if (speed == null || temp == null || previousPower == null ||
|
||||
currentPower == null || !previousPower.isFinite() ||
|
||||
!currentPower.isFinite() || durationMs !in 1..MAX_SAMPLE_GAP_MS
|
||||
) {
|
||||
km = 0.0
|
||||
hours = 0.0
|
||||
kwh = 0.0
|
||||
tempSum = 0.0
|
||||
tempCount = 0
|
||||
continue
|
||||
}
|
||||
if (speed < MIN_SPEED_KMH) continue
|
||||
val intervalHours = durationMs / MILLIS_PER_HOUR
|
||||
km += speed * intervalHours
|
||||
hours += intervalHours
|
||||
// Gross, as the attribution measures it: regeneration is a separate ledger line.
|
||||
kwh += (max(0.0, previousPower) + max(0.0, currentPower)) / 2.0 * intervalHours
|
||||
tempSum += temp
|
||||
tempCount++
|
||||
if (km < WINDOW_KM) continue
|
||||
val meanSpeed = km / hours
|
||||
val meanTemp = tempSum / tempCount
|
||||
val consumption = kwh * 100.0 / km
|
||||
km = 0.0
|
||||
hours = 0.0
|
||||
kwh = 0.0
|
||||
tempSum = 0.0
|
||||
tempCount = 0
|
||||
if (meanSpeed !in MIN_SPEED_KMH..MAX_SPEED_KMH ||
|
||||
meanTemp !in MIN_TEMP_CELSIUS..MAX_TEMP_CELSIUS ||
|
||||
consumption !in 0.0..MAX_CONSUMPTION_KWH_PER_100_KM
|
||||
) {
|
||||
continue
|
||||
}
|
||||
val consumption = power * 100.0 / speed
|
||||
if (!consumption.isFinite() || consumption !in 0.0..MAX_CONSUMPTION_KWH_PER_100_KM) {
|
||||
continue
|
||||
}
|
||||
block(speed, temp, consumption)
|
||||
block(meanSpeed, meanTemp, consumption)
|
||||
accepted++
|
||||
if (accepted == maxSamples) return accepted
|
||||
}
|
||||
@@ -124,9 +174,19 @@ class EnergyModelTrainer(
|
||||
return accepted
|
||||
}
|
||||
|
||||
private fun mean(a: Float?, b: Float?): Double? {
|
||||
val first = a?.toDouble()?.takeIf { it.isFinite() } ?: return null
|
||||
val second = b?.toDouble()?.takeIf { it.isFinite() } ?: return null
|
||||
return (first + second) / 2.0
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MIN_TRAINING_SAMPLES = 60
|
||||
const val MAX_TRAINING_SAMPLES = 50_000
|
||||
/** Kilometre windows, not samples: a dozen, as SocConsumptionFitter asks segments. */
|
||||
const val MIN_TRAINING_SAMPLES = 12
|
||||
const val MAX_TRAINING_SAMPLES = 20_000
|
||||
const val WINDOW_KM = 1.0
|
||||
private const val MAX_SAMPLE_GAP_MS = 120_000L
|
||||
private const val MILLIS_PER_HOUR = 3_600_000.0
|
||||
const val MAX_RESIDUAL_RMSE_KWH_PER_100_KM = 12.0
|
||||
private const val MIN_SPEED_KMH = 10.0
|
||||
private const val MAX_SPEED_KMH = 180.0
|
||||
|
||||
+65
-18
@@ -6,6 +6,7 @@ import com.evsuite.hardware.telemetry.Provenanced
|
||||
import com.evsuite.hardware.telemetry.StoredTrip
|
||||
import com.evsuite.hardware.telemetry.TripSample
|
||||
import com.evsuite.hardware.telemetry.UnavailableReason
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
@@ -148,8 +149,8 @@ class SocConsumptionFitter(
|
||||
* altogether — a trip skipped for a speed conversion that is no longer believed leaves no
|
||||
* trace at all, and a history of them looks exactly like a history of short drives.
|
||||
*
|
||||
* Diagnostic only: it runs the same collection pass as [fit] a second time, so call it
|
||||
* from a probe and not from a screen.
|
||||
* Diagnostic only: it runs the same collection pass and solve as [fit] a second time, so
|
||||
* call it from a probe and not from a screen.
|
||||
*/
|
||||
fun describe(
|
||||
trips: List<StoredTrip>,
|
||||
@@ -161,9 +162,20 @@ class SocConsumptionFitter(
|
||||
"segments=${segments.size}/$MIN_SEGMENTS"
|
||||
if (segments.isEmpty()) return head
|
||||
val span = segments.maxOf { it.meanSpeedKmh } - segments.minOf { it.meanSpeedKmh }
|
||||
return "$head span=${span.roundToInt()}/${MIN_SPEED_SPAN_KMH.roundToInt()} km/h"
|
||||
val line = "$head span=${span.roundToInt()}/${MIN_SPEED_SPAN_KMH.roundToInt()} km/h"
|
||||
if (segments.size < MIN_SEGMENTS || span < MIN_SPEED_SPAN_KMH) return line
|
||||
// Past both gates the line used to stop, and a bundle could not say whether a model
|
||||
// came out of the solve. Appended, so a parser of the prefix above still matches.
|
||||
val evidence = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT)
|
||||
val solved = solve(segments, evidence)
|
||||
val model = solved.model ?: return "$line fit=refused(${solved.refusal})"
|
||||
val rmse = model.residualRmsePercentPer100Km
|
||||
return line + String.format(Locale.ROOT, " fit=ready rmse=%.2f", rmse)
|
||||
}
|
||||
|
||||
/** What [solve] produced: a model, or the name of the guard that refused one. */
|
||||
private class Solved(val model: SocConsumptionModel?, val refusal: String? = null)
|
||||
|
||||
fun fit(
|
||||
trips: List<StoredTrip>,
|
||||
generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(),
|
||||
@@ -180,18 +192,30 @@ class SocConsumptionFitter(
|
||||
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
|
||||
}
|
||||
|
||||
if (segments.maxOf { it.meanSpeedKmh } - segments.minOf { it.meanSpeedKmh } <
|
||||
MIN_SPEED_SPAN_KMH
|
||||
) {
|
||||
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
|
||||
}
|
||||
return solve(segments, evidence).model?.let { SocConsumptionFitResult.Ready(it) }
|
||||
?: SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
|
||||
}
|
||||
|
||||
/** The least-squares solve behind [fit], shared with [describe] so both report one answer. */
|
||||
private fun solve(
|
||||
segments: List<Segment>,
|
||||
evidence: VehicleSpeedEvidence,
|
||||
withWeather: Boolean = true,
|
||||
): Solved {
|
||||
val minSpeed = segments.minOf { it.meanSpeedKmh }
|
||||
val maxSpeed = segments.maxOf { it.meanSpeedKmh }
|
||||
val minTemp = segments.minOf { it.meanOutsideTempCelsius }
|
||||
val maxTemp = segments.maxOf { it.meanOutsideTempCelsius }
|
||||
if (maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH) {
|
||||
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
|
||||
}
|
||||
|
||||
// A driver who has only recorded one kind of weather has no evidence about weather.
|
||||
// Fitting two coefficients is the honest answer there; the envelope keeps the narrow
|
||||
// temperature range, so this model refuses the cold instead of guessing at it.
|
||||
val features = if (maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2
|
||||
val features = if (withWeather && maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2
|
||||
val equations = LeastSquares3(features)
|
||||
for (segment in segments) {
|
||||
equations.add(
|
||||
@@ -200,13 +224,19 @@ class SocConsumptionFitter(
|
||||
segment.percentPer100Km,
|
||||
)
|
||||
}
|
||||
val coefficients = equations.solve()
|
||||
?: return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
|
||||
val coefficients = equations.solve() ?: return Solved(null, "singular")
|
||||
val rolling = coefficients[0]
|
||||
val aero = coefficients[1]
|
||||
val thermal = if (features == 3) coefficients[2] else 0.0
|
||||
if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 || coefficients.any { !it.isFinite() }) {
|
||||
return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
|
||||
when {
|
||||
coefficients.any { !it.isFinite() } -> return Solved(null, "nonfinite")
|
||||
rolling <= 0.0 -> return Solved(null, "rolling")
|
||||
aero < 0.0 -> return Solved(null, "aero")
|
||||
// The weather recorded so far costs nothing measurable: the bundle of 2026-10-16
|
||||
// refused 87 segments over 77 km/h of span on this alone, and the model never
|
||||
// came out on the car. That is the single-weather case again — two coefficients,
|
||||
// and the envelope still refuses a temperature the history never saw.
|
||||
thermal < 0.0 -> return solve(segments, evidence, withWeather = false)
|
||||
}
|
||||
|
||||
var squaredError = 0.0
|
||||
@@ -221,7 +251,7 @@ class SocConsumptionFitter(
|
||||
}
|
||||
val rmse = sqrt(squaredError / segments.size)
|
||||
if (!rmse.isFinite() || rmse > maxResidualRmse) {
|
||||
return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
|
||||
return Solved(null, String.format(Locale.ROOT, "rmse=%.2f", rmse))
|
||||
}
|
||||
|
||||
val model = SocConsumptionModel(
|
||||
@@ -233,11 +263,7 @@ class SocConsumptionFitter(
|
||||
segmentCount = segments.size,
|
||||
envelope = EnergyModelEnvelope(minSpeed, maxSpeed, minTemp, maxTemp),
|
||||
)
|
||||
return if (model.isValid()) {
|
||||
SocConsumptionFitResult.Ready(model)
|
||||
} else {
|
||||
SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
|
||||
}
|
||||
return if (model.isValid()) Solved(model) else Solved(null, "invalid")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,6 +273,11 @@ class SocConsumptionFitter(
|
||||
* refuses to build a segment from is most of what makes the fit trustworthy.
|
||||
*/
|
||||
internal fun collect(samples: List<TripSample>, out: MutableList<Segment>) {
|
||||
// Two steps of this track's own gauge, never more than [minSocDropPercent]. SWI68
|
||||
// publishes tenths of a percent, and waiting for two whole points there threw away
|
||||
// every town drive shorter than ten kilometres — most of what the car is used for.
|
||||
val minDrop = gaugeStep(samples)?.let { minOf(minSocDropPercent, 2.0 * it) }
|
||||
?: minSocDropPercent
|
||||
var startSoc: Double? = null
|
||||
var distanceKm = 0.0
|
||||
var hours = 0.0
|
||||
@@ -302,13 +333,29 @@ class SocConsumptionFitter(
|
||||
continue
|
||||
}
|
||||
val drop = start - soc
|
||||
if (drop < minSocDropPercent) continue
|
||||
// A short stretch keeps accumulating rather than being thrown away: on a fine gauge
|
||||
// the drop arrives before the distance does.
|
||||
if (drop < minDrop || distanceKm < MIN_SEGMENT_KM) continue
|
||||
emit(distanceKm, hours, tempSum, tempCount, drop)?.let(out::add)
|
||||
reset(soc)
|
||||
if (out.size >= maxSegments) return
|
||||
}
|
||||
}
|
||||
|
||||
/** The smallest move this track's gauge made, or null when it never moved. */
|
||||
private fun gaugeStep(samples: List<TripSample>): Double? {
|
||||
var step: Double? = null
|
||||
var previous: Double? = null
|
||||
for (sample in samples) {
|
||||
val soc = sample.socPercent?.toDouble()?.takeIf { it.isFinite() } ?: continue
|
||||
val move = previous?.let { abs(soc - it) }
|
||||
// Float storage turns 0.1 into 0.0999…; anything below a hundredth is that noise.
|
||||
if (move != null && move >= 0.01) step = minOf(step ?: move, move)
|
||||
previous = soc
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
private fun emit(
|
||||
distanceKm: Double,
|
||||
hours: Double,
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<string name="group_comfort">Komfort</string>
|
||||
<string name="group_audio">Audio</string>
|
||||
<string name="group_adas">Fahrerassistenz</string>
|
||||
<string name="group_lights">Beleuchtung</string>
|
||||
<string name="cond_headlights">Abblendlicht</string>
|
||||
<string name="cond_high_beam">Fernlicht an</string>
|
||||
<string name="cond_fog_lights">Nebelscheinwerfer an</string>
|
||||
<string name="act_headlights">Abblendlicht</string>
|
||||
<string name="act_high_beam">Fernlicht</string>
|
||||
<string name="act_fog_lights">Nebelscheinwerfer</string>
|
||||
<string name="light_off">Aus</string>
|
||||
<string name="light_on">An</string>
|
||||
<string name="light_daytime_running">Tagfahrlicht</string>
|
||||
<string name="light_auto">Automatisch</string>
|
||||
<string name="group_climate">Klima</string>
|
||||
<string name="group_profile">Profil</string>
|
||||
<string name="group_system">System</string>
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<string name="group_comfort">Confort</string>
|
||||
<string name="group_audio">Audio</string>
|
||||
<string name="group_adas">Asistencia a la conducción</string>
|
||||
<string name="group_lights">Iluminación</string>
|
||||
<string name="cond_headlights">Luces de cruce</string>
|
||||
<string name="cond_high_beam">Luces largas encendidas</string>
|
||||
<string name="cond_fog_lights">Antiniebla encendidas</string>
|
||||
<string name="act_headlights">Luces de cruce</string>
|
||||
<string name="act_high_beam">Luces largas</string>
|
||||
<string name="act_fog_lights">Antiniebla</string>
|
||||
<string name="light_off">Apagadas</string>
|
||||
<string name="light_on">Encendidas</string>
|
||||
<string name="light_daytime_running">Luces diurnas</string>
|
||||
<string name="light_auto">Automático</string>
|
||||
<string name="group_climate">Climatización</string>
|
||||
<string name="group_profile">Perfil</string>
|
||||
<string name="group_system">Sistema</string>
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<string name="group_comfort">Confort</string>
|
||||
<string name="group_audio">Audio</string>
|
||||
<string name="group_adas">Aides à la conduite</string>
|
||||
<string name="group_lights">Éclairage</string>
|
||||
<string name="cond_headlights">Feux de croisement</string>
|
||||
<string name="cond_high_beam">Feux de route allumés</string>
|
||||
<string name="cond_fog_lights">Antibrouillards allumés</string>
|
||||
<string name="act_headlights">Feux de croisement</string>
|
||||
<string name="act_high_beam">Feux de route</string>
|
||||
<string name="act_fog_lights">Antibrouillards</string>
|
||||
<string name="light_off">Éteints</string>
|
||||
<string name="light_on">Allumés</string>
|
||||
<string name="light_daytime_running">Feux de jour</string>
|
||||
<string name="light_auto">Automatique</string>
|
||||
<string name="group_climate">Climatisation</string>
|
||||
<string name="group_profile">Profil</string>
|
||||
<string name="group_system">Système</string>
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<string name="group_comfort">Comfort</string>
|
||||
<string name="group_audio">Audio</string>
|
||||
<string name="group_adas">Assistenza alla guida</string>
|
||||
<string name="group_lights">Luci</string>
|
||||
<string name="cond_headlights">Anabbaglianti</string>
|
||||
<string name="cond_high_beam">Abbaglianti accesi</string>
|
||||
<string name="cond_fog_lights">Fendinebbia accesi</string>
|
||||
<string name="act_headlights">Anabbaglianti</string>
|
||||
<string name="act_high_beam">Abbaglianti</string>
|
||||
<string name="act_fog_lights">Fendinebbia</string>
|
||||
<string name="light_off">Spente</string>
|
||||
<string name="light_on">Accese</string>
|
||||
<string name="light_daytime_running">Luci diurne</string>
|
||||
<string name="light_auto">Automatico</string>
|
||||
<string name="group_climate">Clima</string>
|
||||
<string name="group_profile">Profilo</string>
|
||||
<string name="group_system">Sistema</string>
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<string name="group_comfort">Conforto</string>
|
||||
<string name="group_audio">Áudio</string>
|
||||
<string name="group_adas">Assistência à condução</string>
|
||||
<string name="group_lights">Iluminação</string>
|
||||
<string name="cond_headlights">Médios</string>
|
||||
<string name="cond_high_beam">Máximos ligados</string>
|
||||
<string name="cond_fog_lights">Faróis de nevoeiro ligados</string>
|
||||
<string name="act_headlights">Médios</string>
|
||||
<string name="act_high_beam">Máximos</string>
|
||||
<string name="act_fog_lights">Faróis de nevoeiro</string>
|
||||
<string name="light_off">Desligados</string>
|
||||
<string name="light_on">Ligados</string>
|
||||
<string name="light_daytime_running">Luzes diurnas</string>
|
||||
<string name="light_auto">Automático</string>
|
||||
<string name="group_climate">Climatização</string>
|
||||
<string name="group_profile">Perfil</string>
|
||||
<string name="group_system">Sistema</string>
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
<string name="group_comfort">Comfort</string>
|
||||
<string name="group_audio">Audio</string>
|
||||
<string name="group_adas">Driver assistance</string>
|
||||
<string name="group_lights">Lights</string>
|
||||
<string name="cond_headlights">Low beams</string>
|
||||
<string name="cond_high_beam">High beam on</string>
|
||||
<string name="cond_fog_lights">Fog lights on</string>
|
||||
<string name="act_headlights">Low beams</string>
|
||||
<string name="act_high_beam">High beam</string>
|
||||
<string name="act_fog_lights">Fog lights</string>
|
||||
<string name="light_off">Off</string>
|
||||
<string name="light_on">On</string>
|
||||
<string name="light_daytime_running">Daytime running</string>
|
||||
<string name="light_auto">Automatic</string>
|
||||
<string name="group_climate">Climate</string>
|
||||
<string name="group_profile">Profile</string>
|
||||
<string name="group_system">System</string>
|
||||
|
||||
@@ -127,13 +127,28 @@ class ReadFailureLogTest {
|
||||
|
||||
class NeverPublishedCatalogueTest {
|
||||
|
||||
@Test fun `SWI68 never publishes battery power or battery temperature`() {
|
||||
CarPropertyEvidence.Signal.entries.forEach { signal ->
|
||||
assertTrue(
|
||||
"$signal is proven absent on SWI68",
|
||||
CarPropertyEvidence.isNeverPublished(signal, FirmwareInfo.Gen.SWI68),
|
||||
@Test fun `SWI68 never publishes battery temperature`() {
|
||||
assertTrue(
|
||||
CarPropertyEvidence.isNeverPublished(
|
||||
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
|
||||
FirmwareInfo.Gen.SWI68,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `SWI68 battery power publishes through the vendor pack and is not validated yet`() {
|
||||
assertFalse(
|
||||
CarPropertyEvidence.isNeverPublished(
|
||||
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
|
||||
FirmwareInfo.Gen.SWI68,
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
CarPropertyEvidence.isValidated(
|
||||
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
|
||||
FirmwareInfo.Gen.SWI68,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `absent is not the same claim as validated`() {
|
||||
@@ -141,13 +156,13 @@ class NeverPublishedCatalogueTest {
|
||||
// never be validated. Both must be false-for-validated and true-for-absent at once.
|
||||
assertFalse(
|
||||
CarPropertyEvidence.isValidated(
|
||||
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
|
||||
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
|
||||
FirmwareInfo.Gen.SWI68,
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
CarPropertyEvidence.isNeverPublished(
|
||||
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
|
||||
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
|
||||
FirmwareInfo.Gen.SWI68,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.evsuite.hardware
|
||||
|
||||
import com.evsuite.hardware.CustomDrive.Family
|
||||
import com.evsuite.hardware.CustomDrive.Setting
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class CustomDriveTest {
|
||||
|
||||
private fun codes(setting: Setting, family: Family) =
|
||||
CustomDrive.INDICES.map { CustomDrive.code(setting, it, family) }
|
||||
|
||||
@Test fun `VSM scale matches the RI-006 read and write codes`() {
|
||||
assertEquals(listOf(1, 2, 3), codes(Setting.POWER, Family.VSM))
|
||||
assertEquals(listOf(1, 2, 3), codes(Setting.STEERING, Family.VSM))
|
||||
assertEquals(listOf(1, 0, 2), codes(Setting.PEDAL, Family.VSM))
|
||||
}
|
||||
|
||||
@Test fun `pedal Normal is zero on every family`() {
|
||||
Family.values().forEach { assertEquals(0, CustomDrive.code(Setting.PEDAL, 1, it)) }
|
||||
}
|
||||
|
||||
@Test fun `A9 powertrain reuses the drive-mode scale`() {
|
||||
assertEquals(listOf(2, 3, 4), codes(Setting.POWER, Family.A9))
|
||||
assertEquals(listOf(1, 2, 3), codes(Setting.STEERING, Family.A9))
|
||||
}
|
||||
|
||||
@Test fun `every code reads back to its index`() {
|
||||
Family.values().forEach { family ->
|
||||
Setting.values().forEach { setting ->
|
||||
CustomDrive.INDICES.forEach { i ->
|
||||
assertEquals(i, CustomDrive.index(setting, CustomDrive.code(setting, i, family)!!, family))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `out of range is refused both ways`() {
|
||||
assertNull(CustomDrive.code(Setting.POWER, 3, Family.VSM))
|
||||
assertNull(CustomDrive.code(Setting.PEDAL, -1, Family.A9))
|
||||
assertNull(CustomDrive.index(Setting.POWER, 0, Family.VSM))
|
||||
assertNull(CustomDrive.index(Setting.POWER, 1, Family.A9))
|
||||
assertNull(CustomDrive.index(Setting.STEERING, -1, Family.VSM))
|
||||
}
|
||||
|
||||
@Test fun `only VSM generations have a route`() {
|
||||
assertEquals(Family.VSM, CustomDrive.familyOf(FirmwareInfo.Gen.SWI68))
|
||||
assertEquals(Family.VSM, CustomDrive.familyOf(FirmwareInfo.Gen.SWI165))
|
||||
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI69))
|
||||
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI131))
|
||||
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI132))
|
||||
assertNull(CustomDrive.familyOf(FirmwareInfo.Gen.SWI133))
|
||||
assertNull(CustomDrive.familyOf(FirmwareInfo.Gen.UNKNOWN))
|
||||
}
|
||||
|
||||
@Test fun `method names follow the family`() {
|
||||
assertEquals("setBrakePedalLevel", CustomDrive.methodName(Setting.PEDAL, Family.VSM, write = true))
|
||||
assertEquals("getDrivingPowerTrainMode", CustomDrive.methodName(Setting.POWER, Family.A9, write = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.evsuite.hardware
|
||||
|
||||
import com.evsuite.hardware.catalog.ActionType
|
||||
import com.evsuite.hardware.catalog.ConditionType
|
||||
import com.evsuite.hardware.probe.VendorSurfaceRules
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* CR-046. Owner decisions pinned here, off the car: a rule never switches the low beams OFF,
|
||||
* never forces the high beam ON, and touches no light on a generation nobody has proven.
|
||||
*/
|
||||
class ExteriorLightsTest {
|
||||
|
||||
private val gen = FirmwareInfo.Gen.SWI68
|
||||
private val proven = setOf(gen)
|
||||
|
||||
@Test
|
||||
fun `low beams can be set ON or AUTOMATIC, never OFF`() {
|
||||
val allowed = ExteriorLights.HEADLIGHT_SWITCH_ALLOWED
|
||||
assertNotNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_OFF, proven))
|
||||
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_ON, proven))
|
||||
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_AUTOMATIC, proven))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `high beam can be set OFF or AUTOMATIC, never forced ON`() {
|
||||
val allowed = ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED
|
||||
assertNotNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_ON, proven))
|
||||
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_OFF, proven))
|
||||
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_AUTOMATIC, proven))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daytime running is never a value a rule can write`() {
|
||||
listOf(
|
||||
ExteriorLights.HEADLIGHT_SWITCH_ALLOWED,
|
||||
ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED,
|
||||
ExteriorLights.FOG_SWITCH_ALLOWED,
|
||||
).forEach { assertFalse(ExteriorLights.SWITCH_DAYTIME_RUNNING in it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unproven generation refuses every light write`() {
|
||||
assertNotNull(
|
||||
ExteriorLights.writeRefusal(
|
||||
FirmwareInfo.Gen.SWI69, ExteriorLights.FOG_SWITCH_ALLOWED, ExteriorLights.SWITCH_ON, proven
|
||||
)
|
||||
)
|
||||
// Until a capture proves one, no generation is proven at all.
|
||||
assertTrue(ExteriorLights.PROVEN.isEmpty())
|
||||
FirmwareInfo.Gen.entries.forEach { assertFalse(ExteriorLights.isProven(it)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daytime running lights are not headlights on`() {
|
||||
assertEquals(true, ExteriorLights.isOn(ExteriorLights.STATE_ON))
|
||||
assertEquals(false, ExteriorLights.isOn(ExteriorLights.STATE_DAYTIME_RUNNING))
|
||||
assertEquals(false, ExteriorLights.isOn(ExteriorLights.STATE_OFF))
|
||||
assertNull(ExteriorLights.isOn(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the editor cannot even express a forbidden light value`() {
|
||||
val lowBeam = ActionType.SET_HEADLIGHTS.spec.options.map { it.value }.toSet()
|
||||
val highBeam = ActionType.SET_HIGH_BEAM.spec.options.map { it.value }.toSet()
|
||||
assertEquals(ExteriorLights.HEADLIGHT_SWITCH_ALLOWED, lowBeam)
|
||||
assertEquals(ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED, highBeam)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `light actions are gated and unproven, light entries supported nowhere yet`() {
|
||||
val actions = listOf(ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS)
|
||||
actions.forEach {
|
||||
assertTrue("${it.name} must be gated", it.gated)
|
||||
assertFalse("${it.name} must stay unproven until a capture", it.writeProven)
|
||||
assertEquals(emptySet<FirmwareGen>(), FirmwareSupport.gensOf(it))
|
||||
}
|
||||
listOf(ConditionType.HEADLIGHTS, ConditionType.HIGH_BEAM_ON, ConditionType.FOG_LIGHTS_ON).forEach {
|
||||
assertEquals(emptySet<FirmwareGen>(), FirmwareSupport.gensOf(it))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a light property is named in the capture diff`() {
|
||||
assertEquals(
|
||||
"CPM 0x11400E00/0 (HEADLIGHTS_STATE)",
|
||||
VendorSurfaceRules.label("CPM 0x11400E00/0"),
|
||||
)
|
||||
assertEquals("CPM 0x2160F404/0", VendorSurfaceRules.label("CPM 0x2160F404/0"))
|
||||
assertEquals("VehicleSetting.getX", VendorSurfaceRules.label("VehicleSetting.getX"))
|
||||
}
|
||||
}
|
||||
+66
-6
@@ -8,12 +8,6 @@ class PhysicalButtonEventDecoderTest {
|
||||
@Test fun `every R69 hardkey code decodes`() {
|
||||
val expected = mapOf(
|
||||
5 to PhysicalButtonEventDecoder.Button.PHONE,
|
||||
6 to PhysicalButtonEventDecoder.Button.UP,
|
||||
7 to PhysicalButtonEventDecoder.Button.DOWN,
|
||||
8 to PhysicalButtonEventDecoder.Button.OK,
|
||||
9 to PhysicalButtonEventDecoder.Button.LEFT,
|
||||
10 to PhysicalButtonEventDecoder.Button.RIGHT,
|
||||
16 to PhysicalButtonEventDecoder.Button.PHONE,
|
||||
23 to PhysicalButtonEventDecoder.Button.CENTER,
|
||||
24 to PhysicalButtonEventDecoder.Button.VOLUME_UP,
|
||||
25 to PhysicalButtonEventDecoder.Button.VOLUME_DOWN,
|
||||
@@ -32,6 +26,72 @@ class PhysicalButtonEventDecoderTest {
|
||||
assertEquals(button, decoder.accept(code, false, false)?.button)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `the SystemUI broadcast adds only the cluster pad`() {
|
||||
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
|
||||
val expected = mapOf(
|
||||
6 to PhysicalButtonEventDecoder.Button.UP,
|
||||
7 to PhysicalButtonEventDecoder.Button.DOWN,
|
||||
8 to PhysicalButtonEventDecoder.Button.OK,
|
||||
9 to PhysicalButtonEventDecoder.Button.LEFT,
|
||||
10 to PhysicalButtonEventDecoder.Button.RIGHT
|
||||
)
|
||||
expected.forEach { (code, button) ->
|
||||
val decoder = PhysicalButtonEventDecoder()
|
||||
decoder.accept(code, true, false, source = systemUi)
|
||||
assertEquals(button, decoder.accept(code, false, false, source = systemUi)?.button)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `a press on both broadcasts is one press`() {
|
||||
// Next track: 87 on the report, 5 on SystemUI. Read as one space, SystemUI's 5 was a
|
||||
// phone press; and the phone itself (5 and 16) arrived twice, i.e. as a double.
|
||||
val decoder = PhysicalButtonEventDecoder()
|
||||
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
|
||||
val events = listOfNotNull(
|
||||
decoder.accept(87, true, false, atMillis = 0),
|
||||
decoder.accept(5, true, false, atMillis = 0, source = systemUi),
|
||||
decoder.accept(87, false, false, atMillis = 50),
|
||||
decoder.accept(5, false, false, atMillis = 50, source = systemUi),
|
||||
decoder.accept(5, true, false, atMillis = 1_000),
|
||||
decoder.accept(16, true, false, atMillis = 1_000, source = systemUi),
|
||||
decoder.accept(5, false, false, atMillis = 1_050),
|
||||
decoder.accept(16, false, false, atMillis = 1_050, source = systemUi)
|
||||
)
|
||||
assertEquals(listOf("MEDIA_NEXT:SHORT", "PHONE:SHORT"), events.map { it.value })
|
||||
}
|
||||
|
||||
@Test fun `an unlisted key is reported with an id of its own`() {
|
||||
val decoder = PhysicalButtonEventDecoder()
|
||||
decoder.accept(3, true, false)
|
||||
val event = decoder.accept(3, false, false)!!
|
||||
assertNull(event.button)
|
||||
assertEquals(
|
||||
PhysicalButtonEventDecoder.Source.HARDKEY_REPORT to 3,
|
||||
PhysicalButtonEventDecoder.unknownKey(event.keyId)
|
||||
)
|
||||
// Same code, other broadcast, other key.
|
||||
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
|
||||
decoder.accept(30, true, false, source = systemUi)
|
||||
val other = decoder.accept(30, false, false, source = systemUi)!!
|
||||
assertEquals(PhysicalButtonEventDecoder.Source.SYSTEM_UI to 30, PhysicalButtonEventDecoder.unknownKey(other.keyId))
|
||||
assertEquals("KEY_${event.keyId}:SHORT", event.value)
|
||||
}
|
||||
|
||||
@Test fun `stored button ids are unchanged`() {
|
||||
// Rules on disk hold these numbers; changing one silently rebinds a rule.
|
||||
val ids = PhysicalButtonEventDecoder.Button.entries.associate { it.name to it.id }
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"PHONE" to 5, "UP" to 6, "DOWN" to 7, "OK" to 8, "LEFT" to 9, "RIGHT" to 10,
|
||||
"SOURCE" to 110, "CENTER" to 23, "VOLUME_UP" to 24, "VOLUME_DOWN" to 25,
|
||||
"MEDIA_NEXT" to 87, "MEDIA_PREVIOUS" to 88, "MUTE" to 164, "STAR_LEFT" to 17,
|
||||
"STAR_RIGHT" to 286, "ASSISTANT" to 287
|
||||
),
|
||||
ids
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `two releases inside the window make one double`() {
|
||||
val decoder = PhysicalButtonEventDecoder()
|
||||
decoder.accept(17, true, false, atMillis = 0)
|
||||
|
||||
+11
-2
@@ -108,7 +108,8 @@ class CatalogConsistencyTest {
|
||||
ActionType.SET_ELK_MODE, ActionType.SET_ELK_SENSITIVITY,
|
||||
ActionType.SET_ACC_TJA_MODE, ActionType.SET_LIMITER_MODE,
|
||||
ActionType.SET_TSR, ActionType.SET_OVERSPEED_ALARM,
|
||||
ActionType.SET_SPEED_LIMIT_TONE, ActionType.SET_SOUND_WARNING
|
||||
ActionType.SET_SPEED_LIMIT_TONE, ActionType.SET_SOUND_WARNING,
|
||||
ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS
|
||||
)
|
||||
|
||||
mustBeGated.forEach { type ->
|
||||
@@ -325,7 +326,15 @@ class CatalogConsistencyTest {
|
||||
}
|
||||
// Exactly these: an unproven write is invisible to the user, so adding one elsewhere
|
||||
// silently removes an action from every app that carries this catalogue.
|
||||
assertEquals(glass.toSet(), ActionType.entries.filter { !it.writeProven }.toSet())
|
||||
// CR-046: the exterior-light actions are unproven too, on purpose, until a capture
|
||||
// proves a generation (see ExteriorLightsTest).
|
||||
val lights = setOf(ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS)
|
||||
// CP-088: the charge limit reads back as a code on SWI68 while the write sends a percent.
|
||||
val chargeLimit = setOf(ActionType.SET_CHARGE_LIMIT)
|
||||
assertEquals(
|
||||
glass.toSet() + lights + chargeLimit,
|
||||
ActionType.entries.filter { !it.writeProven }.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.evsuite.hardware.diag
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import java.util.Base64
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DiagnosticInboxTest {
|
||||
|
||||
private val artifact = DiagnosticInbox.Artifact(
|
||||
app = "EVTasker", version = "2.17.0", name = "evtasker-diagnostic.txt",
|
||||
createdAtMs = 1_792_323_000_000L, content = "report ✓".toByteArray(),
|
||||
)
|
||||
|
||||
@Test fun `only a plain https address is an inbox`() {
|
||||
assertNull(DiagnosticInbox.refuse("https://nodered.example/evsuite"))
|
||||
assertNotNull(DiagnosticInbox.refuse("http://192.0.2.10:1880/evsuite"))
|
||||
assertNotNull(DiagnosticInbox.refuse("https://user:pw@host/evsuite"))
|
||||
assertNotNull(DiagnosticInbox.refuse("https://host/evsuite?token=1"))
|
||||
assertNotNull(DiagnosticInbox.refuse("evsuite"))
|
||||
}
|
||||
|
||||
@Test fun `the body carries the artifact byte for byte`() {
|
||||
var sent: String? = null
|
||||
val outcome = DiagnosticInbox.send("https://host/evsuite", artifact) { _, body -> sent = body; 200 }
|
||||
|
||||
assertEquals(DiagnosticInbox.Outcome.Sent(200), outcome)
|
||||
val json = JsonParser.parseString(sent).asJsonObject
|
||||
assertEquals(1, json["evsuite"].asInt)
|
||||
assertEquals("EVTasker", json["app"].asString)
|
||||
assertEquals("report ✓", String(Base64.getDecoder().decode(json["contentBase64"].asString)))
|
||||
}
|
||||
|
||||
@Test fun `a refused address never reaches the network, a server error is a failure`() {
|
||||
var called = false
|
||||
val refused = DiagnosticInbox.send("http://host/evsuite", artifact) { _, _ -> called = true; 200 }
|
||||
assertTrue(refused is DiagnosticInbox.Outcome.Failed && !called)
|
||||
assertEquals(
|
||||
DiagnosticInbox.Outcome.Failed("HTTP 500"),
|
||||
DiagnosticInbox.send("https://host/evsuite", artifact) { _, _ -> 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -91,4 +91,25 @@ class RuntimeInterfaceProbeTest {
|
||||
val calls = RuntimeInterfaceProbe.CONFIG_CALLS
|
||||
assertEquals(calls.size, calls.values.map { it.code }.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the component map keeps the manifest's words and says when it stopped listing`() {
|
||||
val rows = (0 until RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 3).map {
|
||||
RuntimeInterfaceProbe.ServiceRow("com.saicmotor.x.Service$it", it == 0, null)
|
||||
}
|
||||
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.x", rows)
|
||||
assertEquals("componentMap:com.saicmotor.x", entry.name)
|
||||
assertEquals(BindVerdict.ANSWERED, entry.verdict)
|
||||
assertEquals((rows.size).toString(), entry.values["services"])
|
||||
assertEquals("exported=true permission=null", entry.values["com.saicmotor.x.Service0"])
|
||||
assertEquals("3", entry.values["truncated"])
|
||||
assertEquals(RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 2, entry.values.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a package that declares no service was still read`() {
|
||||
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.y", emptyList())
|
||||
assertEquals(BindVerdict.ANSWERED, entry.verdict)
|
||||
assertEquals(mapOf("services" to "0"), entry.values)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.evsuite.hardware.probe
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The before/after capture and the SOC survey call getters by reflection on vendor objects, in a
|
||||
* car with a driver in it. The one thing that must never happen is a call that changes something,
|
||||
* so the selection is pinned here, off the car, where a wrong answer is a failing test rather
|
||||
* than a setting that moved.
|
||||
*/
|
||||
class VendorSurfaceRulesTest {
|
||||
|
||||
@Suppress("unused")
|
||||
class FakeVendorClient {
|
||||
fun getAutoHighBeamSetting(): Int = 1
|
||||
fun isAutoHighBeamEnabled(): Boolean = true
|
||||
fun getBatterySoc(): Float = 63f
|
||||
fun hasRainSensor(): Boolean = false
|
||||
fun getVersionName(): String = "x"
|
||||
fun getStatusBoxed(): Int? = 2
|
||||
|
||||
fun setAutoHighBeamSetting(value: Int) = Unit
|
||||
fun getAndResetCounter(): Int = 0
|
||||
fun getInstance(): FakeVendorClient = this
|
||||
fun getService(): Any = Any()
|
||||
fun getListenerCount(): Int = 0
|
||||
fun getLevel(area: Int): Int = area
|
||||
fun requestState(): Int = 0
|
||||
fun getter(): Int = 0
|
||||
fun gettable(): Int = 0
|
||||
fun getValues(): IntArray = intArrayOf()
|
||||
fun startCharging(): Boolean = true
|
||||
|
||||
companion object {
|
||||
@JvmStatic fun getStaticValue(): Int = 0
|
||||
}
|
||||
}
|
||||
|
||||
private val names = VendorSurfaceRules.readableGetters(FakeVendorClient::class.java).map { it.name }
|
||||
|
||||
@Test
|
||||
fun `value getters are read, including Setting and Enabled names`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"getAutoHighBeamSetting", "getBatterySoc", "getStatusBoxed", "getVersionName",
|
||||
"hasRainSensor", "isAutoHighBeamEnabled",
|
||||
),
|
||||
names,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing that acts, takes an argument or returns an object is called`() {
|
||||
listOf(
|
||||
"setAutoHighBeamSetting", "getAndResetCounter", "getInstance", "getService",
|
||||
"getListenerCount", "getLevel", "requestState", "getter", "gettable", "getValues",
|
||||
"startCharging", "getStaticValue", "getClass", "hashCode", "toString",
|
||||
).forEach { assertFalse(it, it in names) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keywords narrow the survey, case-insensitively`() {
|
||||
val soc = VendorSurfaceRules.readableGetters(FakeVendorClient::class.java, listOf("SOC", "battery"))
|
||||
assertEquals(listOf("getBatterySoc"), soc.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `camel-case words split on case and underscore`() {
|
||||
assertEquals(listOf("and", "reset", "counter"), VendorSurfaceRules.words("AndResetCounter"))
|
||||
assertEquals(listOf("high", "beam", "setting"), VendorSurfaceRules.words("High_BeamSetting"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the diff lists changed, appeared and vanished values, and nothing else`() {
|
||||
val before = mapOf("a" to "1", "b" to "2", "gone" to "x")
|
||||
val after = mapOf("a" to "1", "b" to "3", "new" to "y")
|
||||
assertEquals(
|
||||
listOf(
|
||||
VendorSurfaceRules.Change("b", "2", "3"),
|
||||
VendorSurfaceRules.Change("gone", "x", null),
|
||||
VendorSurfaceRules.Change("new", null, "y"),
|
||||
),
|
||||
VendorSurfaceRules.diff(before, after),
|
||||
)
|
||||
assertTrue(VendorSurfaceRules.diff(before, before).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty diff says why it may be empty`() {
|
||||
val text = VendorSurfaceRules.formatDiff(emptyList(), 10, 10)
|
||||
assertTrue(text.contains("0 changed"))
|
||||
assertTrue(text.contains("nothing changed"))
|
||||
val one = VendorSurfaceRules.formatDiff(listOf(VendorSurfaceRules.Change("k", null, "1")), 1, 2)
|
||||
assertTrue(one.contains("k: — → 1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `arrays print their content`() {
|
||||
assertEquals("[1, 2]", VendorSurfaceRules.render(intArrayOf(1, 2)))
|
||||
assertEquals("[0.5]", VendorSurfaceRules.render(floatArrayOf(0.5f)))
|
||||
assertEquals("null", VendorSurfaceRules.render(null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.evsuite.hardware.saic
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** The charge limit read refuses what the car could never have been set to. */
|
||||
class SaicChargeLimitTest {
|
||||
|
||||
@Test fun `the code SWI68 answers is not a percent`() {
|
||||
assertNull(SaicCharging.chargeLimitOrNull(5))
|
||||
}
|
||||
|
||||
@Test fun `the range the car accepts reads through, ends included`() {
|
||||
assertEquals(40, SaicCharging.chargeLimitOrNull(40))
|
||||
assertEquals(80, SaicCharging.chargeLimitOrNull(80))
|
||||
assertEquals(100, SaicCharging.chargeLimitOrNull(100))
|
||||
}
|
||||
|
||||
@Test fun `outside it, and unread, is unknown`() {
|
||||
assertNull(SaicCharging.chargeLimitOrNull(39))
|
||||
assertNull(SaicCharging.chargeLimitOrNull(101))
|
||||
assertNull(SaicCharging.chargeLimitOrNull(null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
class BatteryDigestTest {
|
||||
|
||||
private fun day(
|
||||
day: String,
|
||||
health: Double? = 94.0,
|
||||
band: Double? = 3.0,
|
||||
calibration: CalibrationVerdict? = CalibrationVerdict.SETTLED,
|
||||
) = BatteryDigest(
|
||||
day = day,
|
||||
healthPercent = health,
|
||||
healthBandPercent = band,
|
||||
healthWindows = if (health == null) 0 else 5,
|
||||
calibration = calibration,
|
||||
daysSinceFullCharge = 4.0,
|
||||
hoursAboveHighSoc = 10.0,
|
||||
equivalentFullCycles = 12.0,
|
||||
chargeCount = 7,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `movement inside the band is not a change`() {
|
||||
assertTrue(BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", health = 92.0)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movement past the band is one change`() {
|
||||
val change = BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", health = 90.0)).single()
|
||||
assertEquals(DigestChange.HealthMoved(94.0, 90.0, 3.0), change)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a band that narrows by a point is said, less is rounding`() {
|
||||
val narrowed = BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", band = 2.0))
|
||||
assertEquals(listOf(DigestChange.HealthBandNarrowed(3.0, 2.0, 94.0)), narrowed)
|
||||
assertTrue(BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", band = 2.5)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first estimate is news, and the first day has nothing to compare`() {
|
||||
val first = BatteryDigest.changes(day("2026-09-28", health = null, band = null), day("2026-09-29"))
|
||||
assertEquals(listOf(DigestChange.HealthBandNarrowed(null, 3.0, 94.0)), first)
|
||||
assertTrue(BatteryDigest.changes(null, day("2026-09-29")).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calibration verdict that changes is a change`() {
|
||||
val change = BatteryDigest.changes(
|
||||
day("2026-09-28"),
|
||||
day("2026-09-29", calibration = CalibrationVerdict.WATCH),
|
||||
).single() as DigestChange.CalibrationChanged
|
||||
assertEquals(CalibrationVerdict.WATCH, change.to)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the store keeps one record a day, a bounded year, and returns the day before`() {
|
||||
val dir = Files.createTempDirectory("digest").toFile()
|
||||
val store = BatteryDigestStore(File(dir, BatteryDigestStore.FILE_NAME), maxDays = 2)
|
||||
assertNull(store.upsert(day("2026-09-27")))
|
||||
assertEquals("2026-09-27", store.upsert(day("2026-09-28"))?.day)
|
||||
assertEquals("2026-09-27", store.upsert(day("2026-09-28", health = 80.0))?.day)
|
||||
store.upsert(day("2026-09-29"))
|
||||
assertEquals(listOf("2026-09-28", "2026-09-29"), store.read().map { it.day })
|
||||
assertEquals(80.0, store.read().first().healthPercent!!, 1e-9)
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable file is set aside, not trusted`() {
|
||||
val dir = Files.createTempDirectory("digest").toFile()
|
||||
val file = File(dir, BatteryDigestStore.FILE_NAME).apply { writeText("{not json") }
|
||||
assertTrue(BatteryDigestStore(file).read().isEmpty())
|
||||
assertTrue(dir.listFiles()!!.any { it.name.contains("quarantine") })
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,16 @@ class BatteryLedgerTest {
|
||||
assertTrue(recorder.observe(snapshot(atMs = 1_000L, soc = 50f, chargingStatus = 1)))
|
||||
}
|
||||
|
||||
@Test fun `a reading taken on the factory clock earns nothing`() {
|
||||
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
|
||||
val recorder = BatteryLedgerRecorder(store)
|
||||
val factoryClock = 1_546_214_400_000L - NOW_MS // 2018-12-31, as the woken head unit reads
|
||||
|
||||
assertFalse(recorder.observe(snapshot(atMs = factoryClock, soc = 80f)))
|
||||
assertTrue(recorder.observe(snapshot(atMs = 0L, soc = 80f)))
|
||||
assertEquals(listOf(NOW_MS), store.read().map { it.atMs })
|
||||
}
|
||||
|
||||
@Test fun `the integrals are cumulative and bounded by the same five seconds a trip uses`() {
|
||||
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
|
||||
val recorder = BatteryLedgerRecorder(store)
|
||||
@@ -134,7 +144,7 @@ class BatteryLedgerTest {
|
||||
consumedKwh: Float? = null,
|
||||
regeneratedKwh: Float? = null,
|
||||
) = EnergySnapshot(
|
||||
timestampMs = atMs,
|
||||
timestampMs = NOW_MS + atMs,
|
||||
firmware = FirmwareInfo.Gen.SWI68,
|
||||
socPercent = soc,
|
||||
rangeKm = null,
|
||||
@@ -156,4 +166,9 @@ class BatteryLedgerTest {
|
||||
)
|
||||
|
||||
private fun tempDirectory(): File = Files.createTempDirectory("ledger").toFile()
|
||||
|
||||
private companion object {
|
||||
/** 2026-10-11, past the recorder's clock floor. */
|
||||
const val NOW_MS = 1_791_728_806_645L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChargeDurationTest {
|
||||
|
||||
/** A watched charge from [from] to [to] %, one step per point, [minutesOf] minutes for each. */
|
||||
private fun charge(from: Int, to: Int, outside: Double? = 15.0, minutesOf: (Int) -> Double) = ChargeEnergy(
|
||||
session = BatteryChargeSession(0L, 1L, from.toDouble(), to.toDouble(), 0.0, 0.0, outside),
|
||||
watched = true,
|
||||
maxEntryGapMs = 0L,
|
||||
packDeltaKwh = null,
|
||||
meanPowerKw = null,
|
||||
counterBehaviour = ChargeCounterBehaviour.RESET,
|
||||
consumedDeltaKwh = null,
|
||||
regeneratedDeltaKwh = null,
|
||||
movedKm = 0.0,
|
||||
chargingStatuses = emptyList(),
|
||||
steps = (from until to).map {
|
||||
ChargePowerStep(it.toDouble(), it + 1.0, (minutesOf(it) * 60_000).toLong(), 7.0)
|
||||
},
|
||||
minOutsideTempCelsius = outside,
|
||||
maxOutsideTempCelsius = outside,
|
||||
)
|
||||
|
||||
/** Three minutes a point below 80 %, six above: the taper. */
|
||||
private val tapered = { soc: Int -> if (soc < 80) 3.0 else 6.0 }
|
||||
|
||||
@Test
|
||||
fun `duration sums the bands on the path, and the last stretch is its own figure`() {
|
||||
val history = listOf(charge(20, 100, minutesOf = tapered))
|
||||
val estimate = (ChargeDuration.estimate(history, 50.0, 100, null) as ChargeDurationResult.Ready).estimate
|
||||
assertEquals(30 * 3.0 + 20 * 6.0, estimate.minutes, 1e-6)
|
||||
assertEquals(120.0, estimate.lastStretchMinutes!!, 1e-6)
|
||||
assertFalse(estimate.similarTemperature)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the range is the fastest and the slowest past charge`() {
|
||||
val history = listOf(
|
||||
charge(40, 80) { 2.0 },
|
||||
charge(40, 80) { 3.0 },
|
||||
charge(40, 80) { 5.0 },
|
||||
)
|
||||
val estimate = (ChargeDuration.estimate(history, 60.0, 80, null) as ChargeDurationResult.Ready).estimate
|
||||
assertEquals(60.0, estimate.minutes, 1e-6)
|
||||
assertEquals(40.0, estimate.lowMinutes, 1e-6)
|
||||
assertEquals(100.0, estimate.highMinutes, 1e-6)
|
||||
assertNull(estimate.lastStretchMinutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a band nobody watched is refused, never extrapolated`() {
|
||||
val refused = ChargeDuration.estimate(listOf(charge(20, 80, minutesOf = tapered)), 50.0, 100, null)
|
||||
assertEquals(ChargeDurationResult.Refused(ChargeDurationRefusal.MISSING_BAND, 80), refused)
|
||||
assertEquals(
|
||||
ChargeDurationResult.Refused(ChargeDurationRefusal.NO_WATCHED_CHARGE),
|
||||
ChargeDuration.estimate(emptyList(), 50.0, 100, null),
|
||||
)
|
||||
assertEquals(
|
||||
ChargeDurationResult.Refused(ChargeDurationRefusal.AT_TARGET),
|
||||
ChargeDuration.estimate(emptyList(), 80.0, 80, null),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a similar outside temperature is preferred, and all charges are the fallback`() {
|
||||
val history = listOf(
|
||||
charge(40, 80, outside = 0.0) { 5.0 },
|
||||
charge(40, 80, outside = 20.0) { 3.0 },
|
||||
)
|
||||
val cold = (ChargeDuration.estimate(history, 60.0, 80, 2.0) as ChargeDurationResult.Ready).estimate
|
||||
assertTrue(cold.similarTemperature)
|
||||
assertEquals(100.0, cold.minutes, 1e-6)
|
||||
val hot = (ChargeDuration.estimate(history, 60.0, 80, 35.0) as ChargeDurationResult.Ready).estimate
|
||||
assertFalse(hot.similarTemperature)
|
||||
assertEquals(20 * 5.0, hot.minutes, 1e-6) // the median of two is the upper one
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the curve check needs three past charges and flags only slower than all of them`() {
|
||||
val two = listOf(charge(80, 90) { 6.0 }, charge(80, 90) { 7.0 })
|
||||
assertNull(ChargeDuration.curveCheck(two, 80, 20.0))
|
||||
val three = two + charge(80, 90) { 8.0 }
|
||||
assertTrue(ChargeDuration.curveCheck(three, 80, 9.0)!!.slower)
|
||||
val usual = ChargeDuration.curveCheck(three, 80, 7.5)!!
|
||||
assertFalse(usual.slower)
|
||||
assertEquals(6.0, usual.usualLowMinutesPerPoint, 1e-9)
|
||||
assertEquals(3, usual.pastCharges)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChargeEnergyTest {
|
||||
|
||||
@Test fun `a watched charge carries its power and the sign the integral moved`() {
|
||||
// Three quarters of an hour at sixteen points an hour, with the pack pair integral
|
||||
// falling by 8,25 kWh: the declared convention — positive leaves the battery — holding
|
||||
// across a charge, and 11 kW of it.
|
||||
val entries = listOf(
|
||||
entry(0L, 40f, pack = 100.0, odometer = 10_000f),
|
||||
entry(15 * MINUTE, 44f, pack = 97.25, odometer = 10_000f),
|
||||
entry(30 * MINUTE, 48f, pack = 94.5, odometer = 10_000f),
|
||||
entry(45 * MINUTE, 52f, pack = 91.75, odometer = 10_000f),
|
||||
)
|
||||
|
||||
val report = analyse(entries)
|
||||
|
||||
assertEquals(1, report.charges.size)
|
||||
val charge = report.charges.single()
|
||||
assertTrue(charge.watched)
|
||||
assertTrue(charge.plugged)
|
||||
assertEquals(-8.25, charge.packDeltaKwh!!, 1e-6)
|
||||
assertEquals(11.0, charge.meanPowerKw!!, 1e-6)
|
||||
assertEquals(ChargePackSign.ENERGY_FELL, report.packSign)
|
||||
assertEquals(charge, report.lastPluggedCharge)
|
||||
}
|
||||
|
||||
@Test fun `an integral that rises during a charge is reported as the other convention`() {
|
||||
val entries = listOf(
|
||||
entry(0L, 40f, pack = 10.0),
|
||||
entry(15 * MINUTE, 45f, pack = 17.0),
|
||||
entry(30 * MINUTE, 50f, pack = 24.0),
|
||||
)
|
||||
|
||||
assertEquals(ChargePackSign.ENERGY_ROSE, analyse(entries).packSign)
|
||||
}
|
||||
|
||||
@Test fun `two charges that disagree settle nothing`() {
|
||||
val entries = listOf(
|
||||
entry(0L, 40f, pack = 100.0),
|
||||
entry(15 * MINUTE, 45f, pack = 96.5),
|
||||
entry(30 * MINUTE, 50f, pack = 93.0),
|
||||
// A drive in between, so what follows is a second session rather than the same one.
|
||||
entry(45 * MINUTE, 30f, pack = 99.0),
|
||||
entry(60 * MINUTE, 35f, pack = 102.5),
|
||||
entry(75 * MINUTE, 40f, pack = 106.0),
|
||||
)
|
||||
|
||||
assertEquals(ChargePackSign.CONTRADICTORY, analyse(entries).packSign)
|
||||
}
|
||||
|
||||
@Test fun `a charge the app slept through carries no power and no sign`() {
|
||||
// The recorder earns an entry every quarter of an hour while it is running, so a
|
||||
// six-hour silence is the head unit having been off — and the integral did not move
|
||||
// during it, which would otherwise read as a charge that took no energy at all.
|
||||
val entries = listOf(
|
||||
entry(0L, 30f, pack = 100.0),
|
||||
entry(6 * HOUR, 90f, pack = 100.0),
|
||||
)
|
||||
|
||||
val report = analyse(entries)
|
||||
val charge = report.charges.single()
|
||||
assertFalse(charge.watched)
|
||||
assertNull(charge.meanPowerKw)
|
||||
assertEquals(ChargePackSign.UNREADABLE, charge.sign)
|
||||
assertEquals(ChargePackSign.UNREADABLE, report.packSign)
|
||||
}
|
||||
|
||||
@Test fun `a counter that goes backwards across a charge is the car resetting it`() {
|
||||
val entries = listOf(
|
||||
entry(0L, 40f, consumed = 12f, regenerated = 3f),
|
||||
entry(15 * MINUTE, 55f, consumed = 12f, regenerated = 3f),
|
||||
entry(30 * MINUTE, 70f, consumed = 0f, regenerated = 0f),
|
||||
)
|
||||
|
||||
val report = analyse(entries)
|
||||
assertEquals(ChargeCounterBehaviour.RESET, report.counterBehaviour)
|
||||
assertEquals(-12.0, report.charges.single().consumedDeltaKwh!!, 1e-6)
|
||||
}
|
||||
|
||||
@Test fun `counters read at both ends and unchanged are static, never absent`() {
|
||||
val entries = listOf(
|
||||
entry(0L, 40f, consumed = 12f, regenerated = 3f),
|
||||
entry(20 * MINUTE, 70f, consumed = 12f, regenerated = 3f),
|
||||
)
|
||||
|
||||
assertEquals(ChargeCounterBehaviour.STATIC, analyse(entries).counterBehaviour)
|
||||
}
|
||||
|
||||
@Test fun `a car with no counters at all says absent rather than static`() {
|
||||
val entries = listOf(entry(0L, 40f), entry(20 * MINUTE, 70f))
|
||||
|
||||
assertEquals(ChargeCounterBehaviour.ABSENT, analyse(entries).counterBehaviour)
|
||||
}
|
||||
|
||||
@Test fun `a reset seen once outranks a charge that ended before the reset`() {
|
||||
val entries = listOf(
|
||||
entry(0L, 40f, consumed = 9f),
|
||||
entry(15 * MINUTE, 60f, consumed = 9f),
|
||||
entry(30 * MINUTE, 30f, consumed = 14f),
|
||||
entry(45 * MINUTE, 70f, consumed = 0f),
|
||||
)
|
||||
|
||||
assertEquals(ChargeCounterBehaviour.RESET, analyse(entries).counterBehaviour)
|
||||
}
|
||||
|
||||
@Test fun `a rise while the odometer moves is regeneration, never the last charge`() {
|
||||
val entries = listOf(
|
||||
entry(0L, 40f, pack = 100.0, odometer = 10_000f),
|
||||
entry(20 * MINUTE, 48f, pack = 96.0, odometer = 10_020f),
|
||||
)
|
||||
|
||||
val report = analyse(entries)
|
||||
val charge = report.charges.single()
|
||||
assertEquals(20.0, charge.movedKm!!, 1e-6)
|
||||
assertFalse(charge.plugged)
|
||||
assertNull(report.lastPluggedCharge)
|
||||
// The sign is still evidence: energy entering the pack is energy entering the pack.
|
||||
assertEquals(ChargePackSign.ENERGY_FELL, report.packSign)
|
||||
}
|
||||
|
||||
@Test fun `a night the head unit slept through is a charge even when the drive home was not recorded`() {
|
||||
// The reported case: plugged in overnight, battery page empty the next morning. The last
|
||||
// entry before sleep was written before the unrecorded drive home, so the odometer moved
|
||||
// between the two ends and the old rule read fifty points as a descent.
|
||||
val entries = listOf(
|
||||
entry(0L, 38f, pack = 100.0, odometer = 10_000f, temp = 12f),
|
||||
entry(11 * HOUR, 88f, pack = 100.0, odometer = 10_014f, temp = 7f),
|
||||
)
|
||||
|
||||
val charge = analyse(entries).lastPluggedCharge!!
|
||||
assertFalse(charge.watched)
|
||||
assertTrue(charge.plugged)
|
||||
assertNull(charge.meanPowerKw)
|
||||
assertTrue(charge.steps.isEmpty())
|
||||
assertEquals(7.0, charge.minOutsideTempCelsius!!, 1e-6)
|
||||
assertEquals(12.0, charge.maxOutsideTempCelsius!!, 1e-6)
|
||||
}
|
||||
|
||||
@Test fun `the car saying it charges is enough without an odometer`() {
|
||||
val entries = listOf(
|
||||
entry(0L, 40f, status = 1),
|
||||
entry(10 * MINUTE, 43f, status = 1),
|
||||
)
|
||||
|
||||
assertTrue(analyse(entries).charges.single().plugged)
|
||||
}
|
||||
|
||||
@Test fun `a small rise across a gap while the car moved stays unclaimed`() {
|
||||
val entries = listOf(
|
||||
entry(0L, 40f, odometer = 10_000f),
|
||||
entry(3 * HOUR, 46f, odometer = 10_030f),
|
||||
)
|
||||
|
||||
assertNull(analyse(entries).lastPluggedCharge)
|
||||
}
|
||||
|
||||
@Test fun `a watched charge carries its curve, its peak and its taper by band`() {
|
||||
// 7 kW up to 80 %, then the pack takes 3,5 kW above it.
|
||||
val entries = listOf(
|
||||
entry(0L, 77f, pack = 100.0, odometer = 10_000f),
|
||||
entry(6 * MINUTE, 78f, pack = 99.3, odometer = 10_000f),
|
||||
entry(12 * MINUTE, 79f, pack = 98.6, odometer = 10_000f),
|
||||
entry(18 * MINUTE, 80f, pack = 97.9, odometer = 10_000f),
|
||||
entry(30 * MINUTE, 81f, pack = 97.2, odometer = 10_000f),
|
||||
)
|
||||
|
||||
val report = analyse(entries)
|
||||
val charge = report.charges.single()
|
||||
assertEquals(4, charge.steps.size)
|
||||
assertEquals(7.0, charge.peakPowerKw!!, 1e-6)
|
||||
val bands = report.powerBySocBand()
|
||||
assertEquals(listOf(70, 80), bands.map { it.fromPercent })
|
||||
assertEquals(7.0, bands[0].meanPowerKw, 1e-6)
|
||||
assertEquals(3.5, bands[1].meanPowerKw, 1e-6)
|
||||
assertTrue(report.describe().any { it.startsWith("soc_band 80-90 mean_power_kw=3.50") })
|
||||
}
|
||||
|
||||
@Test fun `the description names every question and stays one line per charge`() {
|
||||
val entries = listOf(
|
||||
entry(0L, 40f, pack = 100.0, odometer = 10_000f, status = 2),
|
||||
entry(20 * MINUTE, 55f, pack = 86.0, odometer = 10_000f, status = 2),
|
||||
)
|
||||
|
||||
val lines = analyse(entries).describe()
|
||||
|
||||
assertTrue(lines.any { it == "pack_pair_sign=ENERGY_FELL" })
|
||||
assertTrue(lines.any { it == "vehicle_counters=ABSENT" })
|
||||
assertTrue(lines.any { it == "charging_status_values=2" })
|
||||
assertEquals(1, lines.count { it.startsWith("charge ") })
|
||||
}
|
||||
|
||||
@Test fun `an empty ledger describes itself without inventing a verdict`() {
|
||||
val report = ChargeEnergyAnalyzer().analyse(emptyList(), emptyList())
|
||||
|
||||
assertEquals(ChargePackSign.UNREADABLE, report.packSign)
|
||||
assertEquals(ChargeCounterBehaviour.ABSENT, report.counterBehaviour)
|
||||
assertEquals(listOf("charges=0"), report.describe())
|
||||
}
|
||||
|
||||
/** The sessions are the exposure report's, never re-segmented — the analyzer's own rule. */
|
||||
private fun analyse(entries: List<BatteryLedgerEntry>): ChargeEnergyReport {
|
||||
val sessions = BatteryExposure().analyse(entries)?.sessions.orEmpty()
|
||||
return ChargeEnergyAnalyzer().analyse(entries, sessions)
|
||||
}
|
||||
|
||||
private fun entry(
|
||||
atMs: Long,
|
||||
soc: Float,
|
||||
pack: Double? = null,
|
||||
consumed: Float? = null,
|
||||
regenerated: Float? = null,
|
||||
odometer: Float? = null,
|
||||
status: Int? = null,
|
||||
temp: Float? = null,
|
||||
) = BatteryLedgerEntry(
|
||||
atMs = atMs,
|
||||
socPercent = soc,
|
||||
odometerKm = odometer,
|
||||
vehicleConsumedKwh = consumed,
|
||||
vehicleRegeneratedKwh = regenerated,
|
||||
packEnergyKwh = pack,
|
||||
chargingStatus = status,
|
||||
outsideTempCelsius = temp,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MINUTE = 60_000L
|
||||
const val HOUR = 3_600_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class DrivingStressTest {
|
||||
|
||||
/**
|
||||
* Ten minutes at one sample every ten seconds, the charge falling two points. Minutes 0, 3
|
||||
* and 6 pull 60 kW, minute 9 regenerates 10 kW, the rest cruise at 20 kW.
|
||||
*/
|
||||
private fun track(
|
||||
soc: Float?,
|
||||
outside: Float?,
|
||||
sign: Float = 1f,
|
||||
power: (Int) -> Float? = { i ->
|
||||
when (i / 6) {
|
||||
0, 3, 6 -> 60f
|
||||
9 -> -10f
|
||||
else -> 20f
|
||||
}
|
||||
},
|
||||
) = (0 until 60).map { i ->
|
||||
TripSample(
|
||||
atMs = i * 10_000L,
|
||||
speedKmh = 50f,
|
||||
batteryPowerKw = power(i)?.times(sign),
|
||||
socPercent = soc?.minus(i / 30f),
|
||||
outsideTempCelsius = outside,
|
||||
cabinTempCelsius = null,
|
||||
batteryTempCelsius = null,
|
||||
climatePowerOn = null,
|
||||
climateAcOn = null,
|
||||
climateFanLevel = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hard power in the cold is counted, and cold regeneration summed`() {
|
||||
val stress = DrivingStress.of(track(soc = 60f, outside = -5f))!!
|
||||
assertEquals(3.0, stress.stressedMinutes, 1e-9)
|
||||
assertEquals(50 * 10.0 / 3600.0, stress.coldRegeneratedKwh!!, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the trip orients its own power sign`() {
|
||||
assertEquals(DrivingStress.of(track(soc = 60f, outside = -5f)), DrivingStress.of(track(60f, -5f, sign = -1f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a warm drive at a good charge is a measured zero`() {
|
||||
assertEquals(DrivingStress(0.0, 0.0), DrivingStress.of(track(soc = 60f, outside = 15f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low charge alone is enough, and an unread temperature leaves the regeneration unknown`() {
|
||||
val stress = DrivingStress.of(track(soc = 19f, outside = null))!!
|
||||
assertEquals(3.0, stress.stressedMinutes, 1e-9)
|
||||
assertNull(stress.coldRegeneratedKwh)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown is never zero`() {
|
||||
// Hard moments at a good charge with no temperature: they cannot be decided.
|
||||
assertNull(DrivingStress.of(track(soc = 60f, outside = null)))
|
||||
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { null }))
|
||||
assertNull(DrivingStress.of(track(soc = null, outside = -5f)))
|
||||
// A third of the power unread.
|
||||
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { i -> if (i % 3 == 0) null else 20f }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import com.evsuite.hardware.FirmwareInfo
|
||||
import com.evsuite.hardware.VehicleSpeedEvidence
|
||||
import com.evsuite.hardware.telemetry.model.EnergyModelEnvelope
|
||||
import com.evsuite.hardware.telemetry.model.SocConsumptionFitter
|
||||
import com.evsuite.hardware.telemetry.model.SocConsumptionModel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class EcoDrivingTest {
|
||||
|
||||
private val generation = FirmwareInfo.Gen.SWI68
|
||||
private val evidence = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT)
|
||||
|
||||
/** Predicts 18.0 % per 100 km at 100 km/h and 20 °C, with a band of 0.98. */
|
||||
private val model = SocConsumptionModel(
|
||||
speedEvidence = evidence,
|
||||
rollingPercentPer100Km = 10.0,
|
||||
aeroPercentPer100KmPerSpeedSquared = 0.0008,
|
||||
thermalPercentPer100KmPerDegree = 0.2,
|
||||
residualRmsePercentPer100Km = 0.5,
|
||||
segmentCount = SocConsumptionFitter.MIN_SEGMENTS,
|
||||
envelope = EnergyModelEnvelope(20.0, 160.0, -10.0, 35.0),
|
||||
)
|
||||
|
||||
/** 50 km in half an hour, so the mean speed the model is asked about is 100 km/h. */
|
||||
private fun trip(
|
||||
startSoc: Float? = 90f,
|
||||
endSoc: Float? = 81f,
|
||||
distanceKm: Double = 50.0,
|
||||
durationMs: Long = 1_800_000L,
|
||||
speedEvidence: VehicleSpeedEvidence? = evidence,
|
||||
) = EnergyTripSummary(
|
||||
startedAtMs = 0L,
|
||||
endedAtMs = durationMs,
|
||||
durationMs = durationMs,
|
||||
distanceKm = distanceKm,
|
||||
startSocPercent = startSoc,
|
||||
endSocPercent = endSoc,
|
||||
consumedKwh = null,
|
||||
regeneratedKwh = null,
|
||||
distanceAvailable = true,
|
||||
speedEvidence = speedEvidence,
|
||||
)
|
||||
|
||||
private fun verdict(
|
||||
monitor: EcoDrivingMonitor = EcoDrivingMonitor(),
|
||||
trip: EnergyTripSummary? = trip(),
|
||||
model: SocConsumptionModel? = this.model,
|
||||
tempCelsius: Float? = 20f,
|
||||
) = monitor.verdict(model, trip, tempCelsius, generation)
|
||||
|
||||
/** A steady cruise, one sample a second, from which no lever can fire. */
|
||||
private fun cruise(speedKmh: Float, seconds: Int, fromMs: Long = 0L): EcoDrivingMonitor {
|
||||
val monitor = EcoDrivingMonitor()
|
||||
(0..seconds).forEach { monitor.add(fromMs + it * 1_000L, speedKmh) }
|
||||
return monitor
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drive below the expectation is better than it`() {
|
||||
// 7 % over 50 km is 14 per 100 km, below the 18.0 the fit expects by more than its band.
|
||||
val result = verdict(trip = trip(endSoc = 83f))
|
||||
assertEquals(EcoBand.BETTER, result.band.value)
|
||||
assertEquals(14.0, result.observedPercentPer100Km!!, 1e-9)
|
||||
assertEquals(18.0, result.expectedPercentPer100Km!!, 1e-9)
|
||||
assertTrue(result.deltaPercent!! < 0.0)
|
||||
assertEquals(100.0, result.meanSpeedKmh!!, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drive inside the fit's own band is typical`() {
|
||||
assertEquals(EcoBand.TYPICAL, verdict().band.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drive above the expectation is worse than it`() {
|
||||
val result = verdict(trip = trip(endSoc = 78f))
|
||||
assertEquals(EcoBand.WORSE, result.band.value)
|
||||
assertTrue(result.deltaPercent!! > 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the band widens with the fit's residual rather than a threshold of our own`() {
|
||||
// The same 24 per 100 km that is WORSE against a tight fit is TYPICAL against a loose
|
||||
// one: a model that knows little must not be allowed to accuse the driver.
|
||||
val loose = model.copy(residualRmsePercentPer100Km = 7.0)
|
||||
assertEquals(EcoBand.TYPICAL, verdict(trip = trip(endSoc = 78f), model = loose).band.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sixth over the expectation is worse, not typical, against a two-point residual`() {
|
||||
// 21 per 100 km against 18: the 95 % band (±3.9) called this TYPICAL, one residual does not.
|
||||
val fit = model.copy(residualRmsePercentPer100Km = 2.0)
|
||||
assertEquals(EcoBand.WORSE, verdict(trip = trip(endSoc = 79.5f), model = fit).band.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no model is a refusal, not a verdict`() {
|
||||
val result = verdict(model = null)
|
||||
assertNull(result.band.value)
|
||||
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, result.band.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a speed outside the fitted envelope is the model's refusal, carried through`() {
|
||||
// 200 km/h over the 160 the envelope saw.
|
||||
val result = verdict(trip = trip(distanceKm = 100.0))
|
||||
assertNull(result.band.value)
|
||||
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, result.band.reason)
|
||||
assertEquals(200.0, result.meanSpeedKmh!!, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a distance too short to divide by is refused`() {
|
||||
val result = verdict(trip = trip(distanceKm = 1.0, durationMs = 36_000L))
|
||||
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a charge drop below the gauge's own step is refused`() {
|
||||
val result = verdict(trip = trip(endSoc = 89f))
|
||||
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a distance from the superseded speed conversion is refused`() {
|
||||
val stale = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.MPS_TIMES_3_6_V1)
|
||||
val result = verdict(trip = trip(speedEvidence = stale))
|
||||
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no outside temperature is a missing signal, not a comfortable assumption`() {
|
||||
val result = verdict(tempCelsius = null)
|
||||
assertEquals(UnavailableReason.SIGNAL_ABSENT, result.band.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the cruise lever quotes the fit rather than a rule of thumb`() {
|
||||
val advice = verdict(monitor = cruise(110f, 120)).advice
|
||||
assertEquals(EcoLever.CRUISE_SPEED, advice?.lever)
|
||||
assertEquals(110.0, advice!!.fromSpeedKmh!!, 1e-9)
|
||||
assertEquals(100.0, advice.toSpeedKmh!!, 1e-9)
|
||||
// predict(110) is 19.68 and predict(100) is 18.0: 8.5 % less.
|
||||
assertEquals(8.54, advice.savingPercent!!, 0.01)
|
||||
assertNull(advice.harshSharePercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a town speed leaves the cruise lever alone`() {
|
||||
assertNull(verdict(monitor = cruise(50f, 120)).advice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the steadiness lever fires from the speed channel alone`() {
|
||||
val monitor = EcoDrivingMonitor()
|
||||
var atMs = 0L
|
||||
var speed = 0f
|
||||
// Five stop-and-go cycles: 6 s of hard acceleration, then 18 s of cruising.
|
||||
repeat(5) {
|
||||
repeat(6) { speed += 10f; monitor.add(atMs, speed); atMs += 1_000L }
|
||||
repeat(18) { monitor.add(atMs, speed); atMs += 1_000L }
|
||||
speed = 0f
|
||||
}
|
||||
val advice = verdict(monitor = monitor, model = null).advice
|
||||
assertEquals(EcoLever.STEADINESS, advice?.lever)
|
||||
assertTrue(advice!!.harshSharePercent!! >= EcoDrivingMonitor.MIN_HARSH_SHARE_PERCENT)
|
||||
assertNull(advice.savingPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calm drive is measured, not waiting`() {
|
||||
val monitor = EcoDrivingMonitor()
|
||||
(0..30).forEach { monitor.add(it * 1_000L, 50f) }
|
||||
assertNull(monitor.harshSharePercent())
|
||||
(31..90).forEach { monitor.add(it * 1_000L, 50f) }
|
||||
assertEquals(0.0, monitor.harshSharePercent()!!, 1e-9)
|
||||
assertNull(monitor.steadiness())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hard braking counts like hard acceleration`() {
|
||||
val monitor = EcoDrivingMonitor()
|
||||
var atMs = 0L
|
||||
var speed = 0f
|
||||
// Gentle launches (0.69 m/s²) and abrupt stops (2.8 m/s²): only the braking is harsh.
|
||||
repeat(5) {
|
||||
repeat(24) { speed += 2.5f; monitor.add(atMs, speed); atMs += 1_000L }
|
||||
repeat(6) { speed -= 10f; monitor.add(atMs, speed); atMs += 1_000L }
|
||||
}
|
||||
// 6 hard seconds in every 30: the trip review's `DrivingStyle` reads the same 20 %.
|
||||
assertEquals(20.0, monitor.harshSharePercent()!!, 0.5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sampling gap is a gap, not an acceleration`() {
|
||||
val monitor = EcoDrivingMonitor()
|
||||
(0..70).forEach { monitor.add(it * 1_000L, 50f) }
|
||||
// The app was asleep for half a minute and the car is now at 120: unknown motion, and
|
||||
// reading it as one step would invent 3.9 m/s² out of the process having been paused.
|
||||
monitor.add(100_000L, 120f)
|
||||
assertNull(verdict(monitor = monitor, model = null).advice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay accepts the stored track cadence without relaxing live gaps`() {
|
||||
val samples = (0..24).map { index ->
|
||||
TripSample(
|
||||
atMs = index * 5_500L,
|
||||
speedKmh = if (index % 2 == 0) 0f else 60f,
|
||||
batteryPowerKw = null,
|
||||
socPercent = null,
|
||||
outsideTempCelsius = null,
|
||||
cabinTempCelsius = null,
|
||||
batteryTempCelsius = null,
|
||||
climatePowerOn = null,
|
||||
climateAcOn = null,
|
||||
climateFanLevel = null,
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(EcoLever.STEADINESS, EcoDrivingMonitor.forReplay().replay(samples).steadiness()?.lever)
|
||||
assertNull(EcoDrivingMonitor().replay(samples).steadiness())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a window too short to judge says nothing`() {
|
||||
assertNull(verdict(monitor = cruise(110f, 30)).advice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the cruise lever wins when both are measurable`() {
|
||||
val monitor = EcoDrivingMonitor()
|
||||
var atMs = 0L
|
||||
var speed = 100f
|
||||
// Motorway, and driven unevenly: both levers have something to say and only one is said.
|
||||
repeat(5) {
|
||||
repeat(4) { speed += 8f; monitor.add(atMs, speed); atMs += 1_000L }
|
||||
repeat(20) { monitor.add(atMs, speed); atMs += 1_000L }
|
||||
speed = 100f
|
||||
}
|
||||
assertEquals(EcoLever.CRUISE_SPEED, verdict(monitor = monitor).advice?.lever)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaying a stored track is the same monitor the dashboard fed`() {
|
||||
val samples = (0..120).map { index ->
|
||||
TripSample(
|
||||
atMs = index * 1_000L,
|
||||
speedKmh = 110f,
|
||||
batteryPowerKw = null,
|
||||
socPercent = null,
|
||||
outsideTempCelsius = 20f,
|
||||
cabinTempCelsius = null,
|
||||
batteryTempCelsius = null,
|
||||
climatePowerOn = null,
|
||||
climateAcOn = null,
|
||||
climateFanLevel = null,
|
||||
)
|
||||
}
|
||||
val replayed = EcoDrivingMonitor().replay(samples)
|
||||
assertEquals(
|
||||
verdict(monitor = cruise(110f, 120)).advice,
|
||||
verdict(monitor = replayed).advice,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `describe names the refusal it made`() {
|
||||
val lines = verdict(model = null).describe()
|
||||
assertTrue(lines.any { it == "band=unavailable(MODEL_NOT_TRAINED)" })
|
||||
assertTrue(lines.any { it == "observed_percent_per_100km=unavailable" })
|
||||
assertTrue(lines.any { it == "advice=none" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `describe states the verdict and its inputs`() {
|
||||
val lines = verdict(monitor = cruise(110f, 120)).describe()
|
||||
assertTrue(lines.any { it == "band=TYPICAL" })
|
||||
assertTrue(lines.any { it == "observed_percent_per_100km=18.00" })
|
||||
assertTrue(lines.any { it == "expected_percent_per_100km=18.00" })
|
||||
assertNotNull(lines.firstOrNull { it.startsWith("advice=CRUISE_SPEED") })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.evsuite.hardware.telemetry
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class EcoScoreTest {
|
||||
|
||||
private fun trip(
|
||||
distanceKm: Double = 10.0,
|
||||
brakings: Int? = 3,
|
||||
harshMs: Long? = 60_000L,
|
||||
movingMs: Long? = 1_200_000L,
|
||||
) = EnergyTripSummary(
|
||||
startedAtMs = 0L,
|
||||
endedAtMs = 1_200_000L,
|
||||
durationMs = 1_200_000L,
|
||||
distanceKm = distanceKm,
|
||||
startSocPercent = 80f,
|
||||
endSocPercent = 78f,
|
||||
consumedKwh = null,
|
||||
regeneratedKwh = null,
|
||||
distanceAvailable = true,
|
||||
movingMs = movingMs,
|
||||
harshMs = harshMs,
|
||||
harshAccelerations = 2,
|
||||
harshBrakings = brakings,
|
||||
)
|
||||
|
||||
private fun verdict(delta: Double) = EcoVerdict(
|
||||
band = com.evsuite.hardware.telemetry.Provenanced.estimated(EcoBand.TYPICAL, 1.0),
|
||||
deltaPercent = delta,
|
||||
)
|
||||
|
||||
@Test fun `a calm town drive at the usual consumption sits in the sixties, not near 100`() {
|
||||
// 3 hard stops per 10 km, 5 % of moving time hard, consumption exactly as expected.
|
||||
val score = EcoScore.of(trip(), verdict(0.0))!!
|
||||
assertEquals(50, score.anticipation)
|
||||
assertEquals(80, score.smoothness)
|
||||
assertEquals(63, score.efficiency)
|
||||
assertEquals(63, score.total)
|
||||
}
|
||||
|
||||
@Test fun `a part that cannot be measured drops out instead of scoring zero or full`() {
|
||||
val score = EcoScore.of(trip(), verdict = null)!!
|
||||
assertNull(score.efficiency)
|
||||
// (50 × 0.35 + 80 × 0.25) / 0.60
|
||||
assertEquals(63, score.total)
|
||||
}
|
||||
|
||||
@Test fun `a trip recorded before the counters existed has no score at all`() {
|
||||
assertNull(EcoScore.of(trip(brakings = null, harshMs = null, movingMs = null), null))
|
||||
}
|
||||
|
||||
@Test fun `scales clamp at both ends`() {
|
||||
val best = EcoScore.of(trip(brakings = 0, harshMs = 0L), verdict(-40.0))!!
|
||||
assertEquals(100, best.total)
|
||||
val worst = EcoScore.of(trip(brakings = 20, harshMs = 600_000L), verdict(60.0))!!
|
||||
assertEquals(0, worst.total)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,17 @@ class EnergyTelemetryTest {
|
||||
assertEquals(18f, snapshot.batteryPowerKw)
|
||||
}
|
||||
|
||||
@Test fun `one pedal reaches the track as the car's own code, and an unknown code stays unknown`() {
|
||||
val signals = FakeSignals(80f, null, null).apply { mode = 9; regen = 6 }
|
||||
val sample = TripSample.of(EnergyTelemetryReader(signals).read(1_000L))
|
||||
|
||||
assertEquals(6, sample.regenLevelRaw)
|
||||
assertEquals(com.evsuite.hardware.model.RegenLevel.ONE_PEDAL,
|
||||
com.evsuite.hardware.model.RegenLevel.ofRaw(sample.regenLevelRaw))
|
||||
assertEquals(9, sample.driveModeRaw)
|
||||
assertNull(com.evsuite.hardware.model.DriveMode.ofRaw(sample.driveModeRaw))
|
||||
}
|
||||
|
||||
@Test fun `candidate probes exist only in the explicit evidence read`() {
|
||||
val source = FakeSignals(soc = 80f, range = 200f, power = 18f)
|
||||
source.probes = listOf(
|
||||
@@ -70,6 +81,50 @@ class EnergyTelemetryTest {
|
||||
assertEquals(0.001666, result.regeneratedKwh!!, 0.000001)
|
||||
}
|
||||
|
||||
@Test fun `a trip started on the factory clock is dated from the corrected one`() {
|
||||
val factory = 1_546_300_800_000L
|
||||
val synced = 1_792_323_000_000L
|
||||
val trip = EnergyTripAccumulator(factory, 80f)
|
||||
trip.add(snapshot(factory, 30f, 5f))
|
||||
trip.add(snapshot(factory + 1_000L, 30f, 5f))
|
||||
trip.add(snapshot(synced, 30f, 5f))
|
||||
trip.add(snapshot(synced + 1_000L, 30f, 5f))
|
||||
val result = trip.snapshot(synced + 1_000L)
|
||||
|
||||
assertEquals(2_000L, result.durationMs)
|
||||
assertEquals(synced - 1_000L, result.startedAtMs)
|
||||
}
|
||||
|
||||
@Test fun `lifting off into regeneration is not a hard stop`() {
|
||||
val trip = EnergyTripAccumulator(0L, 80f)
|
||||
// 7 km/h a second is about 1.9 m/s², what regeneration on High does with no brake pedal.
|
||||
listOf(50f, 43f, 36f, 29f, 22f, 15f).forEachIndexed { second, speed ->
|
||||
trip.add(snapshot(second * 1_000L, speed, -10f))
|
||||
}
|
||||
val result = trip.snapshot(5_000L)
|
||||
|
||||
assertEquals(0, result.harshBrakings)
|
||||
assertEquals(0L, result.harshMs)
|
||||
}
|
||||
|
||||
@Test fun `a hard stretch counts once, braking and accelerating apart, at the live rate`() {
|
||||
val trip = EnergyTripAccumulator(0L, 80f)
|
||||
listOf(0f, 30f, 60f, 60f, 40f, 20f, 20f, 20f, 0f).forEachIndexed { second, speed ->
|
||||
trip.add(
|
||||
snapshot(second * 1_000L, speed, 5f)
|
||||
.copy(driveModeRaw = if (second < 6) 4 else 2, regenLevelRaw = 6)
|
||||
)
|
||||
}
|
||||
val result = trip.snapshot(8_000L)
|
||||
|
||||
assertEquals(1, result.harshAccelerations)
|
||||
assertEquals(2, result.harshBrakings)
|
||||
assertEquals(8_000L, result.movingMs)
|
||||
assertEquals(5_000L, result.harshMs)
|
||||
assertEquals(4, result.driveModeRaw)
|
||||
assertEquals(6, result.regenLevelRaw)
|
||||
}
|
||||
|
||||
@Test fun `trip keeps distance unknown without a usable speed interval`() {
|
||||
val trip = EnergyTripAccumulator(0L, 80f)
|
||||
trip.add(snapshot(0L, null, 18f))
|
||||
@@ -124,6 +179,10 @@ class EnergyTelemetryTest {
|
||||
private val power: Float?,
|
||||
) : EnergySignalSource {
|
||||
var probes: List<TelemetryEvidenceProbe> = emptyList()
|
||||
var mode: Int? = null
|
||||
var regen: Int? = null
|
||||
override fun driveModeRaw() = mode
|
||||
override fun regenLevelRaw() = regen
|
||||
override fun firmware() = FirmwareInfo.Gen.SWI68
|
||||
override fun socPercent() = soc
|
||||
override fun rangeKm() = range
|
||||
|
||||
@@ -65,6 +65,20 @@ class TelemetryEvidenceTest {
|
||||
assertEquals(1, parked.samples)
|
||||
}
|
||||
|
||||
@Test fun `the widest window is a quantity, recorded as the snapshot carries it`() {
|
||||
val recorder = TelemetryEvidenceRecorder()
|
||||
recorder.record(snapshot(at = 0L, power = null).copy(widestWindowPercent = 40))
|
||||
recorder.record(snapshot(at = 1_000L, power = null))
|
||||
|
||||
val windows = recorder.evidence()
|
||||
.single { it.signal == TelemetryEvidenceRecorder.WINDOWS_WIDEST_PERCENT }
|
||||
assertEquals("windows.widestPercent", windows.signal)
|
||||
assertEquals(SignalKind.NUMERIC, windows.kind)
|
||||
assertEquals(1, windows.samples)
|
||||
assertEquals(1, windows.nulls)
|
||||
assertEquals(40.0, windows.max!!, 0.0)
|
||||
}
|
||||
|
||||
@Test fun `the update period follows value changes, not sampling ticks`() {
|
||||
val recorder = TelemetryEvidenceRecorder()
|
||||
// One second between reads, five seconds between the values the vehicle publishes.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user