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
|
*.iml
|
||||||
.gradle
|
.gradle
|
||||||
/local.properties
|
/local.properties
|
||||||
/.idea/caches
|
/.idea/
|
||||||
/.idea/libraries
|
|
||||||
/.idea/modules.xml
|
|
||||||
/.idea/workspace.xml
|
|
||||||
/.idea/navEditor.xml
|
|
||||||
/.idea/assetWizardSettings.xml
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
/build
|
||||||
/captures
|
/captures
|
||||||
@@ -39,3 +34,7 @@ mise.local.toml
|
|||||||
/EVHardware/build
|
/EVHardware/build
|
||||||
/EVHardware/.gradle
|
/EVHardware/.gradle
|
||||||
/EVHardware/**/build
|
/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.
|
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
|
||||||
EVABRPUploader is part of **EVSuite**. The workspace `AGENTS.md` and normative
|
(status, to-dos, a dated log of every change) and is updated with each change.
|
||||||
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.
|
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
An Android Automotive app for the **MG4 (SAIC eh32)** that reads vehicle telemetry and
|
An app for the **MG4 (SAIC eh32)** head unit plus the backend it talks to:
|
||||||
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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
AbrpUploadService foreground service; scheduler thread does all IPC and HTTP
|
MG4 head unit (Android 9, AAOS, MT2712)
|
||||||
CarPropertyAdapter reflection bridge to android.car (not on the compile classpath)
|
app/ "Nils' MG4" ──live data, consumption, charging──▶ server/ (Go) ──▶ InfluxDB 2 ◀── Grafana
|
||||||
TelemetryPayload pure JSON building — no Android types, unit-tested
|
│ │ relays app releases from the forge
|
||||||
UploadCadence when a tick actually uploads — pure, unit-tested
|
└──live data, straight, with the driver's own key──▶ ABRP (A Better Route Planner)
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM;
|
- **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the
|
||||||
anything inside the service effectively cannot be tested without a car.
|
`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 |
|
## Rules
|
||||||
|---|---|---|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
The stable channel does not merely disable self-update — the updater is **not in the
|
1. **Nothing touches the Kubernetes cluster.** Write and validate manifests only; the owner
|
||||||
APK**.
|
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
|
- Display 1920×720 px at **160 dpi**; the app window is **1778×720 px** (the car's own sidebar),
|
||||||
differ. Property IDs confirmed on one generation are not universal. Anything vehicle-facing
|
about **1778×640 dp** for layouts. Pages must fit that without scrolling where possible.
|
||||||
must tolerate a property simply not existing.
|
- 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 |
|
| Tab | Contents |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| Speed, outside temp, park | `EVHardware` getters (`getVehicleSpeedKmh`, `getOutsideTempCelsius`, `isVehicleInPark`) | all six — EVHardware branches per generation internally |
|
| 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 |
|
||||||
| SOC, range | `CarPropertyAdapter` vendor IDs (`0x2160F404`, `0x2140F41C`) | **SWI68 only** |
|
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
||||||
| Charge rate/port, cabin temp | `CarPropertyAdapter` standard-AAOS IDs | wherever the VHAL implements them |
|
| 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
|
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
||||||
the generation and picks the right underlying API. Fields it covers therefore work on every
|
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
||||||
supported firmware.
|
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
|
**Put logic in pure, unit-tested classes** (no Android types); the service and activity are
|
||||||
`CarPropertyAdapter` are supported only on SWI68 firmware. EVHardware
|
hard to test without a car.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
**Flavours:** `stable` (no updater at all) and `unstable` (the car build: system UID, OTA and USB
|
||||||
mise run check # what CI runs: permission gate + lint + unit tests
|
install). The car always gets `unstable`.
|
||||||
mise run build
|
|
||||||
mise run logs # adb logcat, filtered to this app
|
## 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`,
|
- **Versions:** three-part `versionName` in `app/build.gradle`; raise `versionCode` by one with
|
||||||
a stale Gradle daemon started by VS Code's Java extension is being reused —
|
every build that goes to the car (a lower code is refused as a downgrade).
|
||||||
`pkill -f GradleDaemon` and retry.
|
- **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.
|
| File | What |
|
||||||
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
|
| `SPECS.md` | journal: architecture, status, signals the car lacks, to-dos, dated done-log |
|
||||||
implementation is added as a test dependency; do not remove it.
|
| `README.md` | user-facing overview, install, setup, config file |
|
||||||
- **`service_running` in preferences goes stale-true** after a force-kill. Use
|
| `OTA.md` | releases, the updater's checks, USB install |
|
||||||
`AbrpUploadService.isRunning()`.
|
| `FIRMWARE.md` | the head unit, firmware generations, display |
|
||||||
- **Permission changes fail CI** until allowlisted with a justification.
|
| `server/README.md`, `k8s/README.md` | API, configuration, deployment |
|
||||||
|
| `LICENSE.md` | MIT, EVHardware's PolyForm Noncommercial, the fork chain |
|
||||||
## Honesty requirements
|
| `DESIGN.md`, `CHANGELOG.md` | upstream's design system and release history |
|
||||||
|
|
||||||
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 -->
|
|
||||||
|
|||||||
+2
-2
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
- Charging sessions are now metered on DC only (10 kW and above). An AC charge is an
|
- 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
|
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.
|
the previous DC session's figures next to it.
|
||||||
|
|
||||||
### Fixed
|
### 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
|
- 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
|
`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
|
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
|
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
|
to reach our cache. Approach taken from merthankaraman/DriveHub_Dort, which solved the
|
||||||
same problem on the same head unit.
|
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
|
## Licensing
|
||||||
|
|
||||||
Read [`LICENSE.md`](LICENSE.md) first. The project's licence status is unresolved because
|
Read [`LICENSE.md`](LICENSE.md) first. Contributions are accepted under the MIT Licence,
|
||||||
the upstream fork source has none. Contributions are accepted on the understanding that
|
except changes to `EVHardware/`, which fall under its PolyForm Noncommercial licence.
|
||||||
they are offered under MIT once that is settled.
|
|
||||||
|
|||||||
@@ -8,6 +8,258 @@ All notable changes to this project are documented here. Format follows
|
|||||||
|
|
||||||
### Added
|
### 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()`
|
- **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
|
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,
|
`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/tests.yml)
|
||||||
[](https://github.com/malys/EVHardware/actions/workflows/security.yml)
|
[](https://github.com/malys/EVHardware/actions/workflows/security.yml)
|
||||||
[](https://github.com/malys/EVHardware/actions/workflows/publish.yml)
|
[](https://github.com/malys/EVHardware/actions/workflows/publish.yml)
|
||||||
[](https://github.com/malys/EVHardware/releases)
|
[](https://github.com/malys/EVHardware/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](https://malys.github.io/EVSuite/)
|
||||||
|
|
||||||
> ⚠️ **This library reads and writes a car's settings.** It runs inside apps installed on
|
> ⚠️ **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.
|
> 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
|
> MG and MG4 are third-party marks used only to identify compatibility; this independent
|
||||||
> independent and is not approved by SAIC Motor or MG Motor.
|
> 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
|
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
|
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
|
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.
|
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
|
## Contents
|
||||||
|
|
||||||
|
- [Part of EVSuite](#part-of-evsuite)
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
- [How it works](#how-it-works)
|
- [How it works](#how-it-works)
|
||||||
- [Install](#install)
|
- [Install](#install)
|
||||||
- [The EVSuite](#the-evsuite)
|
|
||||||
- [Building](#building)
|
- [Building](#building)
|
||||||
- [Project documents](#project-documents)
|
- [Project documents](#project-documents)
|
||||||
- [Security](#security)
|
- [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
|
The library itself declares no permissions and no `sharedUserId` — that is the consuming
|
||||||
app's decision.
|
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
|
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
|
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
|
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.
|
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
|
## Building
|
||||||
```bash
|
```bash
|
||||||
./gradlew :lib:testDebugUnitTest # unit tests + regenerates docs/firmware-matrix.md
|
./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 |
|
| Call in progress | `IN_CALL` | on / off | any |
|
||||||
| Drive duration | `DRIVE_DURATION` | 0…600 min (=, <, >) | any |
|
| Drive duration | `DRIVE_DURATION` | 0…600 min (=, <, >) | any |
|
||||||
| Random chance | `RANDOM_CHANCE` | 1…100 % | 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 |
|
| Front door open | `FRONT_DOOR_OPEN` | on / off | SWI133 |
|
||||||
| Data used today | `DATA_USED_TODAY` | 0…100000 MB (=, <, >) | any |
|
| Data used today | `DATA_USED_TODAY` | 0…100000 MB (=, <, >) | any |
|
||||||
| Data used this month | `DATA_USED_MONTH` | 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 |
|
| Air recirculation on | `RECIRC` | on / off | SWI133, SWI68, SWI165, SWI69, SWI131, SWI132 |
|
||||||
| Fan speed | `FAN_SPEED` | 0…11 (=, <, >) | 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 |
|
| 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 |
|
| ECON mode on | `ECON_MODE` | on / off | SWI68, SWI165 |
|
||||||
| Windscreen defroster on | `FRONT_DEFROST` | on / off | SWI68, SWI165 |
|
| Windscreen defroster on | `FRONT_DEFROST` | on / off | SWI68, SWI165 |
|
||||||
| Rear window defroster on | `REAR_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 |
|
| Speed limit tone | `SPEED_LIMIT_TONE` | on / off | SWI133, SWI132 |
|
||||||
| ADAS sound warning | `SOUND_WARNING` | on / off | SWI68, SWI165, SWI69, SWI131, 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.
|
A reading the car does not answer makes the rule **not evaluable**: it does not fire, and it does not count as false.
|
||||||
|
|
||||||
## Actions
|
## 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 |
|
| 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 |
|
| 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 |
|
| Air conditioning | `SET_AC` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
|
||||||
| ECON mode | `SET_ECON` | on / off | — | SWI68, SWI165 |
|
| ECON mode | `SET_ECON` | on / off | — | SWI68, SWI165 |
|
||||||
| Climate AUTO | `SET_CLIMATE_AUTO` | on / off | — | SWI68, SWI165, SWI69, SWI131, SWI132 |
|
| 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 |
|
| 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 |
|
| Allow charging | `SET_CHARGING_ENABLED` | on / off | — | SWI68, SWI165 |
|
||||||
| Scheduled charging | `SET_CHARGE_SCHEDULE` | 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 |
|
| 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 sound warning | `SET_LAS_WARNING_SOUND` | on / off | — | SWI132 |
|
||||||
| Lane departure steering vibration | `SET_LAS_WARNING_VIBRATION` | 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
|
### System
|
||||||
|
|
||||||
| Action | Entry | Asks for | When stopped only | Firmware |
|
| Action | Entry | Asks for | When stopped only | Firmware |
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
| RECIRC | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| RECIRC | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| FAN_SPEED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| FAN_SPEED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| TEMPERATURE_SET | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| TEMPERATURE_SET | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| PASSENGER_TEMP | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| ECON_MODE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| ECON_MODE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| FRONT_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| FRONT_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| REAR_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| REAR_DEFROST | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
@@ -55,12 +55,15 @@
|
|||||||
| WINDOW_PASSENGER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| WINDOW_PASSENGER | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| WINDOW_REAR_LEFT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| WINDOW_REAR_LEFT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| WINDOW_REAR_RIGHT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| WINDOW_REAR_RIGHT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| DOORS_LOCKED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| DOORS_LOCKED | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| FRONT_DOOR_OPEN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
| FRONT_DOOR_OPEN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
| SEAT_HEAT_LEFT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SEAT_HEAT_LEFT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| HEADLIGHTS | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| HIGH_BEAM_ON | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| FOG_LIGHTS_ON | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
| RADIO_PLAYING | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| RADIO_PLAYING | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| MEDIA_VOLUME | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| AEB_ENABLED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| AEB_ENABLED | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -103,9 +106,12 @@
|
|||||||
| SET_SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SET_SEAT_HEAT_RIGHT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SET_STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SET_STEERING_HEAT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SET_SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| SET_SCREEN_BRIGHTNESS | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| SET_HEADLIGHTS ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| SET_HIGH_BEAM ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| SET_FOG_LIGHTS ⚠️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||||
| SET_CLIMATE_POWER | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| SET_CLIMATE_POWER | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| SET_CABIN_TEMP | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| SET_CABIN_TEMP | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| SET_PASSENGER_TEMP | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SET_PASSENGER_TEMP | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SET_AC | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| SET_AC | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| SET_ECON | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SET_ECON | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SET_CLIMATE_AUTO | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| SET_CLIMATE_AUTO | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
@@ -119,7 +125,7 @@
|
|||||||
| SET_WINDOW_REAR_LEFT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SET_WINDOW_REAR_LEFT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SET_WINDOW_REAR_RIGHT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SET_WINDOW_REAR_RIGHT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SET_DOOR_LOCK | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SET_DOOR_LOCK | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SET_CHARGE_LIMIT | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SET_CHARGE_LIMIT ⚠️ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SET_CHARGING_ENABLED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SET_CHARGING_ENABLED | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SET_CHARGE_SCHEDULE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SET_CHARGE_SCHEDULE | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| SET_CHARGE_WINDOW | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
| SET_CHARGE_WINDOW | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
|||||||
@@ -143,12 +143,12 @@ object CarPropertyEvidence {
|
|||||||
* between them, zero readings for either signal, and `EV_INSTANTANEOUS_CHARGE_RATE` and
|
* between them, zero readings for either signal, and `EV_INSTANTANEOUS_CHARGE_RATE` and
|
||||||
* `EV_BATTERY_AVG_TEMP` answering `no CarPropertyValue` on every property probe.
|
* `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
|
* Battery power left this list once the vendor pack voltage × current fallback published:
|
||||||
* power on this generation, because there is no battery power to integrate. An arrival
|
* −32,9 to +32,4 kW on the urban drive of 2026-10-16, net agreeing with the charge drop.
|
||||||
* forecast on this car has to come from state of charge per kilometre instead.
|
* 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(
|
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),
|
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
|
* CarPropertyManager first. But SWI68 does not expose IGNITION_STATE through CPM: the
|
||||||
* property revient vide, et l'état d'allumage passait pour illisible alors que Katman5
|
* property comes back empty, and the ignition state looked unreadable although Katman5
|
||||||
* le rapporte correctement — le log le montre (`Katman5: state initial = 2 (RUN/READY)`)
|
* reports it correctly — the log shows it (`Katman5: state initial = 2 (RUN/READY)`) while
|
||||||
* pendant que le diagnostic classait IGNITION_STATE en NOT_READABLE.
|
* the diagnostics classed IGNITION_STATE as NOT_READABLE.
|
||||||
*
|
*
|
||||||
* D'où le repli sur le dernier état livré par IVehicleConditionService, converti :
|
* Hence the fallback to the last state delivered by IVehicleConditionService, converted:
|
||||||
* Katman5 parle en [CarIgnitionItem], cette fonction rend du [IgnitionState], et les
|
* Katman5 speaks [CarIgnitionItem], this function returns [IgnitionState], and the two
|
||||||
* deux échelles ne coïncident pas (RUN vaut 2 chez l'un, OFF vaut 2 chez l'autre).
|
* 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 {
|
fun getCurrentIgnitionState(): Int {
|
||||||
val v0 = getIntPropertyCPM(PROP_IGNITION_STATE, 0)
|
val v0 = getIntPropertyCPM(PROP_IGNITION_STATE, 0)
|
||||||
@@ -356,7 +356,7 @@ object EVHardware {
|
|||||||
return vcmIgnitionAsAaos() ?: global
|
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) {
|
private fun vcmIgnitionAsAaos(): Int? = when (sLastVcmIgnitionState) {
|
||||||
CarIgnitionItem.OFF -> IgnitionState.OFF
|
CarIgnitionItem.OFF -> IgnitionState.OFF
|
||||||
CarIgnitionItem.ACCESSORY -> IgnitionState.ACC
|
CarIgnitionItem.ACCESSORY -> IgnitionState.ACC
|
||||||
@@ -418,12 +418,15 @@ object EVHardware {
|
|||||||
/** Total mileage, the vendor's own integer — `PERF_ODOMETER` never publishes here. */
|
/** Total mileage, the vendor's own integer — `PERF_ODOMETER` never publishes here. */
|
||||||
private const val PROP_VENDOR_TOTAL_MILEAGE = 0x21401566
|
private const val PROP_VENDOR_TOTAL_MILEAGE = 0x21401566
|
||||||
|
|
||||||
/**
|
// Ids from MG4Control's SWI133 readings; unproven on SWI68. The plug and energy ids are
|
||||||
* Charging session state. The vehicle's own values, from the stock charging screen:
|
// probe-only. PROP_BMS_CHRG_STS is also read by getVendorChargeStatus() — the charging session
|
||||||
* 1 = AC charging, 10 = DC charging, 5 = connecting, 7 = connected but not charging,
|
// state, the vehicle's own values from the stock charging screen: 1 = AC charging,
|
||||||
* 8 = stopped. Anything else is not a session.
|
// 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
|
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.
|
* `HVAC_AMBIENT_TEMPERATURE`, the one in-car candidate left on this firmware.
|
||||||
@@ -479,6 +482,24 @@ object EVHardware {
|
|||||||
?.takeIf { it.isFinite() && it >= 0f }
|
?.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 {
|
fun getStandardRangeKm(): Float? = supportedTelemetryRead {
|
||||||
getFloatPropertyCPM(PROP_RANGE_REMAINING, AREA_GLOBAL)
|
getFloatPropertyCPM(PROP_RANGE_REMAINING, AREA_GLOBAL)
|
||||||
?.takeIf { it.isFinite() && it >= 0f }
|
?.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
|
* 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.
|
* 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? =
|
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 {
|
fun getCabinTemperatureCelsius(): Float? = supportedTelemetryRead {
|
||||||
getFloatPropertyCPM(PROP_CABIN_TEMP, AREA_HVAC)
|
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),
|
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 {
|
private fun probeProperty(name: String, propId: Int, areaId: Int, boxed: Class<*>): PropertyReport {
|
||||||
val cpm = sCarPropertyManager
|
val cpm = sCarPropertyManager
|
||||||
?: return PropertyReport(name, propId, areaId, "CarPropertyManager not connected")
|
?: 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? =
|
private inline fun <T> supportedTelemetryRead(read: () -> T?): T? =
|
||||||
when (FirmwareInfo.getGeneration()) {
|
when (FirmwareInfo.getGeneration()) {
|
||||||
FirmwareInfo.Gen.SWI68,
|
FirmwareInfo.Gen.SWI68,
|
||||||
@@ -1439,7 +1581,7 @@ object EVHardware {
|
|||||||
return try {
|
return try {
|
||||||
val native = (g.javaClass.getMethod("getBrightness").invoke(g) as? Int) ?: return -1
|
val native = (g.javaClass.getMethod("getBrightness").invoke(g) as? Int) ?: return -1
|
||||||
if (native < 0) 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%")
|
AppLogger.d(TAG, " getBrightness native=$native → $pct%")
|
||||||
pct
|
pct
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -1450,7 +1592,7 @@ object EVHardware {
|
|||||||
|
|
||||||
private fun setBrightnessOldSdk(clampedPct: Int): Boolean {
|
private fun setBrightnessOldSdk(clampedPct: Int): Boolean {
|
||||||
val g = sGeneral ?: return false
|
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)")
|
if (logEnabled) AppLogger.i(TAG, "setBrightness → $clampedPct% (native=$native/255)")
|
||||||
return try {
|
return try {
|
||||||
g.javaClass.getMethod("setBrightness", Int::class.javaPrimitiveType).invoke(g, native)
|
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
|
AppLogger.w(TAG, " A9 getBrightness Settings.System exc: ${e.message}"); return -1
|
||||||
}
|
}
|
||||||
if (native < 0) 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 {
|
private fun setBrightnessA9(clampedPct: Int): Boolean {
|
||||||
val resolver = sAppContext?.contentResolver ?: return false
|
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 {
|
return try {
|
||||||
// Manual mode, otherwise auto-brightness overwrites the value at once
|
// Manual mode, otherwise auto-brightness overwrites the value at once
|
||||||
android.provider.Settings.System.putInt(resolver,
|
android.provider.Settings.System.putInt(resolver,
|
||||||
@@ -2329,23 +2471,22 @@ object EVHardware {
|
|||||||
fun getSeatHeatRight(): Int = seatHeatRightOrNull() ?: 0
|
fun getSeatHeatRight(): Int = seatHeatRightOrNull() ?: 0
|
||||||
fun isSteeringHeatOn(): Boolean = steeringHeatOnOrNull() ?: false
|
fun isSteeringHeatOn(): Boolean = steeringHeatOnOrNull() ?: false
|
||||||
|
|
||||||
fun getDriveMode(): DriveMode? {
|
fun getDriveMode(): DriveMode? = getDriveModeRaw()?.let(DriveMode::fromValue)
|
||||||
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 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
|
val cpm = sCarPropertyManager ?: return null
|
||||||
return try {
|
return try {
|
||||||
val raw = (cpm.javaClass
|
cpm.javaClass
|
||||||
.getMethod("getIntProperty", Int::class.java, Int::class.java)
|
.getMethod("getIntProperty", Int::class.java, Int::class.java)
|
||||||
.invoke(cpm, PROP_REGEN_LEVEL, AREA_GLOBAL) as? Int) ?: return null
|
.invoke(cpm, prop, AREA_GLOBAL) as? Int
|
||||||
RegenLevel.fromValue(raw)
|
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2464,8 +2605,9 @@ object EVHardware {
|
|||||||
fun getAccTjaMode(): Int {
|
fun getAccTjaMode(): Int {
|
||||||
val useNewApi = FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132
|
val useNewApi = FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132
|
||||||
val method = if (useNewApi) "getAccTjaState" else "getAccTjaMode"
|
val method = if (useNewApi) "getAccTjaState" else "getAccTjaMode"
|
||||||
if (logEnabled) AppLogger.i(TAG, "$method →")
|
val value = (callVsm(method) as? Int) ?: -1
|
||||||
return (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 */
|
/** 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. */
|
/** Lit le mode du limiteur de speed. Returns 0/2/3, ou -1 si unavailable. */
|
||||||
fun getSpeedLimiterMode(): Int {
|
fun getSpeedLimiterMode(): Int {
|
||||||
val method = if (useSasApi()) "getSasMode" else "getSpeedAsstMode"
|
val method = if (useSasApi()) "getSasMode" else "getSpeedAsstMode"
|
||||||
if (logEnabled) AppLogger.i(TAG, "$method →")
|
val value = (callVsm(method) as? Int) ?: -1
|
||||||
return (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. */
|
/** 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
|
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. */
|
/** Diagnostic button probe: logs the volume + door states at click time. */
|
||||||
fun runDoorVolumeDiag() {
|
fun runDoorVolumeDiag() {
|
||||||
AppLogger.i(VOL_TAG, "── DIAG (bouton Diagnostic) ──")
|
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
|
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
|
* They number their keys in two different spaces, so a code only means something together
|
||||||
* PhysicalKeysBroadcastReceiver; 287 is also named VOICE_KEY_CODE by VrSpeechService.
|
* with the broadcast it came on ([Source]):
|
||||||
* Codes 17 and 18 are firmware aliases already observed by EVProfile.
|
*
|
||||||
|
* - `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 {
|
class PhysicalButtonEventDecoder {
|
||||||
enum class Button(val codes: Set<Int>, val shortKey: String, val longKey: String) {
|
enum class Source { HARDKEY_REPORT, SYSTEM_UI }
|
||||||
PHONE(setOf(5, 16), SnapshotKeys.KEY_PHONE_SHORT, SnapshotKeys.KEY_PHONE_LONG),
|
|
||||||
UP(setOf(6), SnapshotKeys.KEY_UP_SHORT, SnapshotKeys.KEY_UP_LONG),
|
enum class Button(
|
||||||
DOWN(setOf(7), SnapshotKeys.KEY_DOWN_SHORT, SnapshotKeys.KEY_DOWN_LONG),
|
/** On the report broadcast. */
|
||||||
OK(setOf(8), SnapshotKeys.KEY_OK_SHORT, SnapshotKeys.KEY_OK_LONG),
|
val codes: Set<Int>,
|
||||||
LEFT(setOf(9), SnapshotKeys.KEY_LEFT_SHORT, SnapshotKeys.KEY_LEFT_LONG),
|
val shortKey: String,
|
||||||
RIGHT(setOf(10), SnapshotKeys.KEY_RIGHT_SHORT, SnapshotKeys.KEY_RIGHT_LONG),
|
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),
|
SOURCE(setOf(110), SnapshotKeys.KEY_SOURCE_SHORT, SnapshotKeys.KEY_SOURCE_LONG),
|
||||||
CENTER(setOf(23), SnapshotKeys.KEY_CENTER_SHORT, SnapshotKeys.KEY_CENTER_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),
|
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),
|
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_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),
|
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 }
|
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)
|
fun readings(): Map<String, Any> = mapOf(SnapshotKeys.KEY_PHYSICAL_BUTTON_EVENT to value)
|
||||||
}
|
}
|
||||||
private enum class State { DOWN, LONG_REPORTED }
|
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.
|
* 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
|
* 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.
|
* 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
|
* @param atMillis when the event happened. A monotonic default, and JVM-only so the
|
||||||
@@ -53,27 +90,28 @@ class PhysicalButtonEventDecoder {
|
|||||||
down: Boolean,
|
down: Boolean,
|
||||||
longPress: Boolean,
|
longPress: Boolean,
|
||||||
atMillis: Long = System.nanoTime() / 1_000_000,
|
atMillis: Long = System.nanoTime() / 1_000_000,
|
||||||
|
source: Source = Source.HARDKEY_REPORT,
|
||||||
): Event? {
|
): Event? {
|
||||||
val button = Button.entries.firstOrNull { keyCode in it.codes } ?: return null
|
val key = keyId(source, keyCode) ?: return null
|
||||||
return when {
|
return when {
|
||||||
down && longPress && states[button] != State.LONG_REPORTED -> {
|
down && longPress && states[key] != State.LONG_REPORTED -> {
|
||||||
states[button] = State.LONG_REPORTED
|
states[key] = State.LONG_REPORTED
|
||||||
// A long press ends the pairing: the release that follows must not become the
|
// A long press ends the pairing: the release that follows must not become the
|
||||||
// second half of a double the driver never made.
|
// second half of a double the driver never made.
|
||||||
lastShortAt.remove(button)
|
lastShortAt.remove(key)
|
||||||
Event(button, Press.LONG)
|
Event(key, Press.LONG)
|
||||||
}
|
}
|
||||||
down -> { states.putIfAbsent(button, State.DOWN); null }
|
down -> { states.putIfAbsent(key, State.DOWN); null }
|
||||||
states.remove(button) == State.DOWN -> {
|
states.remove(key) == State.DOWN -> {
|
||||||
val previous = lastShortAt[button]
|
val previous = lastShortAt[key]
|
||||||
if (previous != null && atMillis - previous <= DOUBLE_TAP_MS) {
|
if (previous != null && atMillis - previous <= DOUBLE_TAP_MS) {
|
||||||
// Consumed, so three presses read as one double and one single rather than
|
// Consumed, so three presses read as one double and one single rather than
|
||||||
// as two overlapping doubles.
|
// as two overlapping doubles.
|
||||||
lastShortAt.remove(button)
|
lastShortAt.remove(key)
|
||||||
Event(button, Press.DOUBLE)
|
Event(key, Press.DOUBLE)
|
||||||
} else {
|
} else {
|
||||||
lastShortAt[button] = atMillis
|
lastShortAt[key] = atMillis
|
||||||
Event(button, Press.SHORT)
|
Event(key, Press.SHORT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> null
|
else -> null
|
||||||
@@ -91,5 +129,37 @@ class PhysicalButtonEventDecoder {
|
|||||||
* double — see EVTasker's vehicle service.
|
* double — see EVTasker's vehicle service.
|
||||||
*/
|
*/
|
||||||
const val DOUBLE_TAP_MS = 300L
|
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),
|
ENERGY(R.string.group_energy),
|
||||||
AUDIO(R.string.group_audio),
|
AUDIO(R.string.group_audio),
|
||||||
ADAS(R.string.group_adas),
|
ADAS(R.string.group_adas),
|
||||||
|
LIGHTS(R.string.group_lights),
|
||||||
SYSTEM(R.string.group_system)
|
SYSTEM(R.string.group_system)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +166,32 @@ enum class ActionType(
|
|||||||
"SET_SCREEN_BRIGHTNESS", currentKey = SnapshotKeys.KEY_BRIGHTNESS
|
"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) ─────────
|
// ── 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.
|
// 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
|
// 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
|
* 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
|
* one every existing rule sets, and giving it a zone would have made every saved rule
|
||||||
* carry a value it never chose.
|
* 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(
|
SET_PASSENGER_TEMP(
|
||||||
R.string.act_passenger_temp, ActionGroup.CLIMATE,
|
R.string.act_passenger_temp, ActionGroup.CLIMATE,
|
||||||
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
|
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) ─────────
|
// ── 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)
|
@SupportedOn(SWI68, SWI165)
|
||||||
SET_CHARGE_LIMIT(
|
SET_CHARGE_LIMIT(
|
||||||
R.string.act_charge_limit, ActionGroup.ENERGY,
|
R.string.act_charge_limit, ActionGroup.ENERGY,
|
||||||
number(VehicleEnums.CHARGE_LIMIT_MIN, VehicleEnums.CHARGE_LIMIT_MAX, R.string.unit_percent),
|
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)
|
@SupportedOn(SWI68, SWI165)
|
||||||
SET_CHARGING_ENABLED(
|
SET_CHARGING_ENABLED(
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ enum class ConditionGroup(@StringRes val labelRes: Int) {
|
|||||||
CLIMATE(R.string.group_climate),
|
CLIMATE(R.string.group_climate),
|
||||||
COMFORT(R.string.group_comfort),
|
COMFORT(R.string.group_comfort),
|
||||||
AUDIO(R.string.group_audio),
|
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
|
* 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
|
* to tell them apart — and there is no readable "dual zone" flag, so the honest way to
|
||||||
* ask is to compare the two targets.
|
* 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(
|
PASSENGER_TEMP(
|
||||||
R.string.cond_passenger_temp, ConditionGroup.CLIMATE,
|
R.string.cond_passenger_temp, ConditionGroup.CLIMATE,
|
||||||
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
|
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),
|
number(0, 100, R.string.unit_percent),
|
||||||
SnapshotKeys.KEY_WINDOW_REAR_RIGHT, comparable = true
|
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(
|
DOORS_LOCKED(
|
||||||
R.string.cond_doors_locked, ConditionGroup.CONTEXT,
|
R.string.cond_doors_locked, ConditionGroup.CONTEXT,
|
||||||
ValueSpec.BOOL, SnapshotKeys.KEY_DOORS_LOCKED
|
ValueSpec.BOOL, SnapshotKeys.KEY_DOORS_LOCKED
|
||||||
@@ -473,6 +483,26 @@ enum class ConditionType(
|
|||||||
SnapshotKeys.KEY_BRIGHTNESS, comparable = true
|
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 ────────────────────────────────────────────────────────────────
|
// ── Audio ────────────────────────────────────────────────────────────────
|
||||||
// What is coming out of the speakers, and how loud. The picker groups them together for
|
// 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
|
// 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 = "mediaVolume"
|
||||||
const val KEY_MEDIA_VOLUME_MAX = "mediaVolumeMax"
|
const val KEY_MEDIA_VOLUME_MAX = "mediaVolumeMax"
|
||||||
const val KEY_BRIGHTNESS = "brightnessPct"
|
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_ENABLED = "aebEnabled"
|
||||||
const val KEY_AEB_MODE = "aebMode"
|
const val KEY_AEB_MODE = "aebMode"
|
||||||
const val KEY_AEB_SENSITIVITY = "aebSensitivity"
|
const val KEY_AEB_SENSITIVITY = "aebSensitivity"
|
||||||
|
|||||||
@@ -176,4 +176,23 @@ object VehicleEnums {
|
|||||||
EnumOption(2, R.string.radio_band_fm),
|
EnumOption(2, R.string.radio_band_fm),
|
||||||
EnumOption(4, R.string.radio_band_dab)
|
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 {
|
companion object {
|
||||||
fun fromValue(v: Int): DriveMode = values().firstOrNull { it.value == v } ?: NORMAL
|
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 tsrEnabled: Boolean = false,
|
||||||
val isDefault: Boolean = false,
|
val isDefault: Boolean = false,
|
||||||
// [BT-PROFILES] MAC of the Bluetooth device tied to this profile (null = none)
|
// [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 {
|
companion object {
|
||||||
fun fromValue(v: Int): RegenLevel = values().firstOrNull { it.value == v } ?: MEDIUM
|
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
|
package com.evsuite.hardware.probe
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import com.evsuite.hardware.EVHardware
|
import com.evsuite.hardware.EVHardware
|
||||||
import com.evsuite.hardware.FirmwareInfo
|
import com.evsuite.hardware.FirmwareInfo
|
||||||
@@ -61,6 +62,7 @@ object RuntimeInterfaceProbe {
|
|||||||
* it from a capture that has been up for a while.
|
* it from a capture that has been up for a while.
|
||||||
*/
|
*/
|
||||||
fun connect(context: Context) {
|
fun connect(context: Context) {
|
||||||
|
packages = context.applicationContext.packageManager
|
||||||
SaicHub.connect(context)
|
SaicHub.connect(context)
|
||||||
engMode.connect(context)
|
engMode.connect(context)
|
||||||
}
|
}
|
||||||
@@ -276,6 +278,80 @@ object RuntimeInterfaceProbe {
|
|||||||
add(vehicleProperty())
|
add(vehicleProperty())
|
||||||
add(vehicleCharging())
|
add(vehicleCharging())
|
||||||
add(carDiagnostics())
|
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 {
|
): InterfaceProbe {
|
||||||
val target = binder
|
val target = binder
|
||||||
?: return InterfaceProbe(label, descriptor, BindVerdict.ABSENT, note = note)
|
?: 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 values = calls.mapValues { (_, call) ->
|
||||||
val args = call.args.toTypedArray()
|
val args = call.args.toTypedArray()
|
||||||
when (call.readAs) {
|
when (call.readAs) {
|
||||||
@@ -365,6 +451,18 @@ object RuntimeInterfaceProbe {
|
|||||||
*/
|
*/
|
||||||
private val CODE_SWEEP = (0x0..0x12).toList()
|
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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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>) {
|
internal fun Parcel.writeEvRoutPoiList(pois: List<NavigationHandoff.Poi>) {
|
||||||
writeInt(pois.size)
|
writeInt(pois.size)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.os.IBinder
|
|||||||
import com.evsuite.hardware.A9Climate
|
import com.evsuite.hardware.A9Climate
|
||||||
import com.evsuite.hardware.GlassProbe
|
import com.evsuite.hardware.GlassProbe
|
||||||
import com.evsuite.hardware.VsmGlass
|
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.
|
* 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 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 driverTemp(): Int? = if (a9) A9Climate.driverTemp() else level(TX_GET_DRV_TEMP)
|
||||||
fun passengerTemp(): Int? = if (a9) null else level(TX_GET_PSG_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 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)
|
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. */
|
/** 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)
|
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 =
|
fun setChargeLimitPercent(percent: Int): Boolean =
|
||||||
SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_CLOSE_SOC, percent.coerceIn(0, 100))
|
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. */
|
/** @return true when this sample earned an entry and it was written. */
|
||||||
fun observe(snapshot: EnergySnapshot): Boolean {
|
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()
|
seed()
|
||||||
integrate(snapshot)
|
integrate(snapshot)
|
||||||
val soc = snapshot.socPercent?.takeIf { it.isFinite() } ?: return false
|
val soc = snapshot.socPercent?.takeIf { it.isFinite() } ?: return false
|
||||||
@@ -231,5 +234,8 @@ class BatteryLedgerRecorder(
|
|||||||
const val SOC_STEP_PERCENT = 1.0f
|
const val SOC_STEP_PERCENT = 1.0f
|
||||||
const val QUIET_INTERVAL_MS = 15 * 60 * 1000L
|
const val QUIET_INTERVAL_MS = 15 * 60 * 1000L
|
||||||
const val MAX_SAMPLE_GAP_MS = 5_000L
|
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
|
package com.evsuite.hardware.telemetry
|
||||||
|
|
||||||
|
import com.evsuite.hardware.model.DriveMode
|
||||||
|
import com.evsuite.hardware.model.RegenLevel
|
||||||
|
|
||||||
import com.evsuite.hardware.FirmwareInfo
|
import com.evsuite.hardware.FirmwareInfo
|
||||||
|
|
||||||
/** One immutable, nullable, read-only view of the vehicle's energy state. */
|
/** One immutable, nullable, read-only view of the vehicle's energy state. */
|
||||||
@@ -33,6 +36,18 @@ data class EnergySnapshot(
|
|||||||
val tirePressures: TirePressureSnapshot,
|
val tirePressures: TirePressureSnapshot,
|
||||||
val batteryVoltageV: Float? = null,
|
val batteryVoltageV: Float? = null,
|
||||||
val batteryCurrentA: 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
|
val hasVehicleData: Boolean
|
||||||
get() = socPercent != null || rangeKm != null || speedKmh != null ||
|
get() = socPercent != null || rangeKm != null || speedKmh != null ||
|
||||||
@@ -40,7 +55,8 @@ data class EnergySnapshot(
|
|||||||
batteryTempCelsius != null || batteryEnergyKwh != null || batteryCapacityKwh != null ||
|
batteryTempCelsius != null || batteryEnergyKwh != null || batteryCapacityKwh != null ||
|
||||||
odometerKm != null || chargePortConnected != null || chargingStatus != null ||
|
odometerKm != null || chargePortConnected != null || chargingStatus != null ||
|
||||||
vehicleConsumedKwh != null || vehicleRegeneratedKwh != 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(
|
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.SaicCharging
|
||||||
import com.evsuite.hardware.saic.SaicClimate
|
import com.evsuite.hardware.saic.SaicClimate
|
||||||
import com.evsuite.hardware.saic.SaicHub
|
import com.evsuite.hardware.saic.SaicHub
|
||||||
|
import com.evsuite.hardware.saic.SaicVehicleControl
|
||||||
|
|
||||||
/** Injectable signal seam: production uses [EvHardwareEnergySignalSource], tests use a fake. */
|
/** Injectable signal seam: production uses [EvHardwareEnergySignalSource], tests use a fake. */
|
||||||
interface EnergySignalSource {
|
interface EnergySignalSource {
|
||||||
@@ -28,6 +29,11 @@ interface EnergySignalSource {
|
|||||||
fun parked(): Boolean?
|
fun parked(): Boolean?
|
||||||
fun batteryVoltageV(): Float? = null
|
fun batteryVoltageV(): Float? = null
|
||||||
fun batteryCurrentA(): 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 climate(): ClimateSnapshot
|
||||||
fun tirePressures(): TirePressureSnapshot
|
fun tirePressures(): TirePressureSnapshot
|
||||||
/** Probe-only values excluded from [EnergySnapshot] and normal consumers. */
|
/** 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 parked() = EVHardware.isVehicleInPark()
|
||||||
override fun batteryVoltageV() = EVHardware.getVendorBatteryVoltageV()
|
override fun batteryVoltageV() = EVHardware.getVendorBatteryVoltageV()
|
||||||
override fun batteryCurrentA() = EVHardware.getVendorBatteryCurrentA()
|
override fun batteryCurrentA() = EVHardware.getVendorBatteryCurrentA()
|
||||||
|
override fun widestWindowPercent() = SaicVehicleControl.widestWindowPercent()
|
||||||
|
override fun driveModeRaw() = EVHardware.getDriveModeRaw()
|
||||||
|
override fun regenLevelRaw() = EVHardware.getRegenLevelRaw()
|
||||||
override fun tirePressures() = TirePressureSnapshot(
|
override fun tirePressures() = TirePressureSnapshot(
|
||||||
frontLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_LEFT),
|
frontLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_LEFT),
|
||||||
frontRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_RIGHT),
|
frontRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_RIGHT),
|
||||||
@@ -80,7 +89,23 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
|
|||||||
TelemetryEvidenceProbe(
|
TelemetryEvidenceProbe(
|
||||||
TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH,
|
TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH,
|
||||||
EVHardware.probeCurrentBatteryCapacityWh()?.toDouble(),
|
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(
|
override fun climate() = ClimateSnapshot(
|
||||||
powerOn = SaicClimate.powerOn(),
|
powerOn = SaicClimate.powerOn(),
|
||||||
@@ -94,11 +119,9 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
|
|||||||
?: EVHardware.getTemperatureSetCelsius(),
|
?: EVHardware.getTemperatureSetCelsius(),
|
||||||
// The passenger setpoint is its own signal only when the car publishes one: the vendor
|
// 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
|
// 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,
|
// dropped upstream. Null then, not the driver's number: a screen that shows both seats
|
||||||
// so that is what the passenger reads — the same number, because it is the same air.
|
// the same air says so itself, and evidence captured here must not call a copy a reading.
|
||||||
passengerTargetCelsius = SaicClimate.passengerTemp()?.toFloat()
|
passengerTargetCelsius = SaicClimate.passengerTemp()?.toFloat(),
|
||||||
?: SaicClimate.driverTemp()?.toFloat()
|
|
||||||
?: EVHardware.getTemperatureSetCelsius(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +151,9 @@ class EnergyTelemetryReader(private val source: EnergySignalSource) {
|
|||||||
tirePressures = source.tirePressures(),
|
tirePressures = source.tirePressures(),
|
||||||
batteryVoltageV = source.batteryVoltageV()?.takeIf { it.isFinite() },
|
batteryVoltageV = source.batteryVoltageV()?.takeIf { it.isFinite() },
|
||||||
batteryCurrentA = source.batteryCurrentA()?.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. */
|
/** 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].
|
* train on it. See [VehicleSpeedEvidence].
|
||||||
*/
|
*/
|
||||||
val speedEvidence: VehicleSpeedEvidence? = null,
|
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.
|
* 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 hasPowerInterval = false
|
||||||
private var recordedMs = 0L
|
private var recordedMs = 0L
|
||||||
private var latestSocPercent = startSocPercent
|
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) {
|
fun add(sample: EnergySnapshot) {
|
||||||
track.add(sample)
|
track.add(sample)
|
||||||
@@ -90,6 +111,7 @@ class EnergyTripAccumulator(
|
|||||||
hasSpeedInterval = true
|
hasSpeedInterval = true
|
||||||
distanceKm += ((previous.speedKmh + sample.speedKmh) / 2.0) * hours
|
distanceKm += ((previous.speedKmh + sample.speedKmh) / 2.0) * hours
|
||||||
}
|
}
|
||||||
|
countStyle(previous, sample, gapMs)
|
||||||
if (previous.batteryPowerKw != null && sample.batteryPowerKw != null) {
|
if (previous.batteryPowerKw != null && sample.batteryPowerKw != null) {
|
||||||
hasPowerInterval = true
|
hasPowerInterval = true
|
||||||
val intervalKwh =
|
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 samples(): List<TripSample> = track.samples()
|
||||||
|
|
||||||
fun snapshot(nowMs: Long) = EnergyTripSummary(
|
fun snapshot(nowMs: Long) = EnergyTripSummary(
|
||||||
startedAtMs = startedAtMs,
|
startedAtMs = startedAtMs(nowMs),
|
||||||
endedAtMs = nowMs,
|
endedAtMs = nowMs,
|
||||||
durationMs = recordedMs,
|
durationMs = recordedMs,
|
||||||
distanceKm = distanceKm,
|
distanceKm = distanceKm,
|
||||||
@@ -113,9 +161,30 @@ class EnergyTripAccumulator(
|
|||||||
distanceAvailable = hasSpeedInterval,
|
distanceAvailable = hasSpeedInterval,
|
||||||
speedEvidence = speedEvidence.takeIf { hasSpeedInterval },
|
speedEvidence = speedEvidence.takeIf { hasSpeedInterval },
|
||||||
batteryPowerEvidence = batteryPowerEvidence.takeIf { hasPowerInterval },
|
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. */
|
/** 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(
|
class EnergyTripHistoryStore(
|
||||||
private val target: File,
|
private val target: File,
|
||||||
private val maxTrips: Int = 200,
|
private val maxTrips: Int = 200,
|
||||||
private val maxBytes: Int = 512 * 1024,
|
private val maxBytes: Int = 2 * 1024 * 1024,
|
||||||
private val gson: Gson = Gson(),
|
private val gson: Gson = Gson(),
|
||||||
) {
|
) {
|
||||||
/** Trips with whatever tracks survived eviction, newest first. */
|
/** Trips with whatever tracks survived eviction, newest first. */
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ class TelemetryEvidenceRecorder {
|
|||||||
record(CHARGING_STATUS, snapshot.chargingStatus?.toDouble(), at)
|
record(CHARGING_STATUS, snapshot.chargingStatus?.toDouble(), at)
|
||||||
record(CHARGE_PORT_CONNECTED, snapshot.chargePortConnected, at)
|
record(CHARGE_PORT_CONNECTED, snapshot.chargePortConnected, at)
|
||||||
record(PARKED, snapshot.parked, at)
|
record(PARKED, snapshot.parked, at)
|
||||||
|
record(WINDOWS_WIDEST_PERCENT, snapshot.widestWindowPercent?.toDouble(), at)
|
||||||
|
|
||||||
val climate = snapshot.climate
|
val climate = snapshot.climate
|
||||||
record(CLIMATE_POWER_ON, climate.powerOn, at)
|
record(CLIMATE_POWER_ON, climate.powerOn, at)
|
||||||
@@ -250,6 +251,7 @@ class TelemetryEvidenceRecorder {
|
|||||||
const val CHARGING_STATUS = "chargingStatus"
|
const val CHARGING_STATUS = "chargingStatus"
|
||||||
const val CHARGE_PORT_CONNECTED = "chargePortConnected"
|
const val CHARGE_PORT_CONNECTED = "chargePortConnected"
|
||||||
const val PARKED = "parked"
|
const val PARKED = "parked"
|
||||||
|
const val WINDOWS_WIDEST_PERCENT = "windows.widestPercent"
|
||||||
const val CLIMATE_POWER_ON = "climate.powerOn"
|
const val CLIMATE_POWER_ON = "climate.powerOn"
|
||||||
const val CLIMATE_AC_ON = "climate.acOn"
|
const val CLIMATE_AC_ON = "climate.acOn"
|
||||||
const val CLIMATE_AUTO_ON = "climate.autoOn"
|
const val CLIMATE_AUTO_ON = "climate.autoOn"
|
||||||
@@ -261,6 +263,10 @@ class TelemetryEvidenceRecorder {
|
|||||||
const val CLIMATE_PASSENGER_TARGET_C = "climate.passengerTargetCelsius"
|
const val CLIMATE_PASSENGER_TARGET_C = "climate.passengerTargetCelsius"
|
||||||
const val CANDIDATE_CURRENT_BATTERY_CAPACITY_WH =
|
const val CANDIDATE_CURRENT_BATTERY_CAPACITY_WH =
|
||||||
"candidate.evCurrentBatteryCapacityWh"
|
"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)
|
transition(State.RECORDING, timestampMs)
|
||||||
Result(state)
|
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)
|
transition(State.IDLE, timestampMs)
|
||||||
Result(state, Event.STOP)
|
Result(state, Event.STOP)
|
||||||
}
|
}
|
||||||
@@ -117,9 +118,13 @@ class TripDetector {
|
|||||||
|
|
||||||
private fun endConfirmed(snapshot: EnergySnapshot): Boolean {
|
private fun endConfirmed(snapshot: EnergySnapshot): Boolean {
|
||||||
val confirmationAvailable = snapshot.parked != null || snapshot.chargePortConnected != null
|
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 {
|
companion object {
|
||||||
/** Ignores parking creep and noisy zero readings; 5 km/h is unambiguous road motion. */
|
/** Ignores parking creep and noisy zero readings; 5 km/h is unambiguous road motion. */
|
||||||
const val START_SPEED_KMH = 5.0
|
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. */
|
/** Longer than the ticket's 90-second traffic stop while still practical at arrival. */
|
||||||
const val STOP_DEBOUNCE_MS = 120_000L
|
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. */
|
/** Five seconds tolerates scheduler jitter but cannot bridge a meaningful telemetry hole. */
|
||||||
const val MAX_OBSERVATION_GAP_MS = 5_000L
|
const val MAX_OBSERVATION_GAP_MS = 5_000L
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ data class TripSample(
|
|||||||
val climatePowerOn: Boolean?,
|
val climatePowerOn: Boolean?,
|
||||||
val climateAcOn: Boolean?,
|
val climateAcOn: Boolean?,
|
||||||
val climateFanLevel: Int?,
|
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 {
|
companion object {
|
||||||
fun of(snapshot: EnergySnapshot) = TripSample(
|
fun of(snapshot: EnergySnapshot) = TripSample(
|
||||||
@@ -37,6 +45,9 @@ data class TripSample(
|
|||||||
climatePowerOn = snapshot.climate.powerOn,
|
climatePowerOn = snapshot.climate.powerOn,
|
||||||
climateAcOn = snapshot.climate.acOn,
|
climateAcOn = snapshot.climate.acOn,
|
||||||
climateFanLevel = snapshot.climate.fanLevel,
|
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.abs
|
||||||
import kotlin.math.max
|
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 {
|
enum class ResidualFinding {
|
||||||
DISTINGUISHABLE,
|
DISTINGUISHABLE,
|
||||||
@@ -115,7 +122,11 @@ object EnergyAttributionCalculator {
|
|||||||
val modelled = predictedConsumption * distanceKm / 100.0
|
val modelled = predictedConsumption * distanceKm / 100.0
|
||||||
val uncertainty = predictedUncertainty * distanceKm / 100.0
|
val uncertainty = predictedUncertainty * distanceKm / 100.0
|
||||||
val measured = (max(0.0, previousPower) + max(0.0, currentPower)) / 2.0 * hours
|
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
|
tractionKwh += modelled
|
||||||
tractionUncertaintyKwh += uncertainty
|
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 {
|
private fun climateActive(sample: TripSample): Boolean? = when {
|
||||||
sample.climatePowerOn == true || sample.climateAcOn == true ||
|
sample.climatePowerOn == true || sample.climateAcOn == true ||
|
||||||
sample.climateFanLevel?.let { it > 0 } == true -> 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.StoredTrip
|
||||||
import com.evsuite.hardware.telemetry.UnavailableReason
|
import com.evsuite.hardware.telemetry.UnavailableReason
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
sealed interface EnergyModelTrainingResult {
|
sealed interface EnergyModelTrainingResult {
|
||||||
@@ -40,29 +41,33 @@ class EnergyModelTrainer(
|
|||||||
trips: List<StoredTrip>,
|
trips: List<StoredTrip>,
|
||||||
evidence: BatteryPowerEvidence,
|
evidence: BatteryPowerEvidence,
|
||||||
): EnergyModelTrainingResult {
|
): EnergyModelTrainingResult {
|
||||||
|
// Both fits are accumulated in the one pass; the temperature span picks which to solve.
|
||||||
val equations = LeastSquares3()
|
val equations = LeastSquares3()
|
||||||
|
val narrowed = LeastSquares3(2)
|
||||||
var minSpeed = Double.POSITIVE_INFINITY
|
var minSpeed = Double.POSITIVE_INFINITY
|
||||||
var maxSpeed = Double.NEGATIVE_INFINITY
|
var maxSpeed = Double.NEGATIVE_INFINITY
|
||||||
var minTemp = Double.POSITIVE_INFINITY
|
var minTemp = Double.POSITIVE_INFINITY
|
||||||
var maxTemp = Double.NEGATIVE_INFINITY
|
var maxTemp = Double.NEGATIVE_INFINITY
|
||||||
val count = forEachUsable(trips, evidence) { speed, temp, consumption ->
|
val count = forEachUsable(trips, evidence) { speed, temp, consumption ->
|
||||||
equations.add(speed, temp, consumption)
|
equations.add(speed, temp, consumption)
|
||||||
|
narrowed.add(speed, temp, consumption)
|
||||||
minSpeed = minOf(minSpeed, speed)
|
minSpeed = minOf(minSpeed, speed)
|
||||||
maxSpeed = maxOf(maxSpeed, speed)
|
maxSpeed = maxOf(maxSpeed, speed)
|
||||||
minTemp = minOf(minTemp, temp)
|
minTemp = minOf(minTemp, temp)
|
||||||
maxTemp = maxOf(maxTemp, temp)
|
maxTemp = maxOf(maxTemp, temp)
|
||||||
}
|
}
|
||||||
if (count < MIN_TRAINING_SAMPLES || maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH ||
|
if (count < MIN_TRAINING_SAMPLES || maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH) {
|
||||||
maxTemp - minTemp < MIN_TEMPERATURE_SPAN_CELSIUS
|
|
||||||
) {
|
|
||||||
return EnergyModelTrainingResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
|
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)
|
?: return EnergyModelTrainingResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
|
||||||
val rolling = coefficients[0]
|
val rolling = coefficients[0]
|
||||||
val aero = coefficients[1]
|
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 ||
|
if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 ||
|
||||||
coefficients.any { !it.isFinite() }
|
coefficients.any { !it.isFinite() }
|
||||||
) {
|
) {
|
||||||
@@ -93,7 +98,19 @@ class EnergyModelTrainer(
|
|||||||
return EnergyModelTrainingResult.Ready(model)
|
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>,
|
trips: List<StoredTrip>,
|
||||||
evidence: BatteryPowerEvidence,
|
evidence: BatteryPowerEvidence,
|
||||||
block: (speedKmh: Double, outsideTempCelsius: Double, consumption: Double) -> Unit,
|
block: (speedKmh: Double, outsideTempCelsius: Double, consumption: Double) -> Unit,
|
||||||
@@ -101,22 +118,55 @@ class EnergyModelTrainer(
|
|||||||
var accepted = 0
|
var accepted = 0
|
||||||
for (trip in trips) {
|
for (trip in trips) {
|
||||||
if (trip.summary.batteryPowerEvidence != evidence) continue
|
if (trip.summary.batteryPowerEvidence != evidence) continue
|
||||||
for (sample in trip.samples.orEmpty()) {
|
val samples = trip.samples.orEmpty()
|
||||||
val speed = sample.speedKmh?.toDouble()
|
var km = 0.0
|
||||||
val power = sample.batteryPowerKw?.toDouble()
|
var hours = 0.0
|
||||||
val temp = sample.outsideTempCelsius?.toDouble()
|
var kwh = 0.0
|
||||||
if (speed == null || power == null || temp == null ||
|
var tempSum = 0.0
|
||||||
!speed.isFinite() || speed !in MIN_SPEED_KMH..MAX_SPEED_KMH ||
|
var tempCount = 0
|
||||||
!power.isFinite() || power <= 0.0 ||
|
for (index in 1 until samples.size) {
|
||||||
!temp.isFinite() || temp !in MIN_TEMP_CELSIUS..MAX_TEMP_CELSIUS
|
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
|
continue
|
||||||
}
|
}
|
||||||
val consumption = power * 100.0 / speed
|
block(meanSpeed, meanTemp, consumption)
|
||||||
if (!consumption.isFinite() || consumption !in 0.0..MAX_CONSUMPTION_KWH_PER_100_KM) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
block(speed, temp, consumption)
|
|
||||||
accepted++
|
accepted++
|
||||||
if (accepted == maxSamples) return accepted
|
if (accepted == maxSamples) return accepted
|
||||||
}
|
}
|
||||||
@@ -124,9 +174,19 @@ class EnergyModelTrainer(
|
|||||||
return accepted
|
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 {
|
companion object {
|
||||||
const val MIN_TRAINING_SAMPLES = 60
|
/** Kilometre windows, not samples: a dozen, as SocConsumptionFitter asks segments. */
|
||||||
const val MAX_TRAINING_SAMPLES = 50_000
|
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
|
const val MAX_RESIDUAL_RMSE_KWH_PER_100_KM = 12.0
|
||||||
private const val MIN_SPEED_KMH = 10.0
|
private const val MIN_SPEED_KMH = 10.0
|
||||||
private const val MAX_SPEED_KMH = 180.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.StoredTrip
|
||||||
import com.evsuite.hardware.telemetry.TripSample
|
import com.evsuite.hardware.telemetry.TripSample
|
||||||
import com.evsuite.hardware.telemetry.UnavailableReason
|
import com.evsuite.hardware.telemetry.UnavailableReason
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.roundToInt
|
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
|
* 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.
|
* 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
|
* Diagnostic only: it runs the same collection pass and solve as [fit] a second time, so
|
||||||
* from a probe and not from a screen.
|
* call it from a probe and not from a screen.
|
||||||
*/
|
*/
|
||||||
fun describe(
|
fun describe(
|
||||||
trips: List<StoredTrip>,
|
trips: List<StoredTrip>,
|
||||||
@@ -161,9 +162,20 @@ class SocConsumptionFitter(
|
|||||||
"segments=${segments.size}/$MIN_SEGMENTS"
|
"segments=${segments.size}/$MIN_SEGMENTS"
|
||||||
if (segments.isEmpty()) return head
|
if (segments.isEmpty()) return head
|
||||||
val span = segments.maxOf { it.meanSpeedKmh } - segments.minOf { it.meanSpeedKmh }
|
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(
|
fun fit(
|
||||||
trips: List<StoredTrip>,
|
trips: List<StoredTrip>,
|
||||||
generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(),
|
generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(),
|
||||||
@@ -180,18 +192,30 @@ class SocConsumptionFitter(
|
|||||||
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
|
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 minSpeed = segments.minOf { it.meanSpeedKmh }
|
||||||
val maxSpeed = segments.maxOf { it.meanSpeedKmh }
|
val maxSpeed = segments.maxOf { it.meanSpeedKmh }
|
||||||
val minTemp = segments.minOf { it.meanOutsideTempCelsius }
|
val minTemp = segments.minOf { it.meanOutsideTempCelsius }
|
||||||
val maxTemp = segments.maxOf { 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.
|
// 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
|
// 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.
|
// 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)
|
val equations = LeastSquares3(features)
|
||||||
for (segment in segments) {
|
for (segment in segments) {
|
||||||
equations.add(
|
equations.add(
|
||||||
@@ -200,13 +224,19 @@ class SocConsumptionFitter(
|
|||||||
segment.percentPer100Km,
|
segment.percentPer100Km,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val coefficients = equations.solve()
|
val coefficients = equations.solve() ?: return Solved(null, "singular")
|
||||||
?: return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
|
|
||||||
val rolling = coefficients[0]
|
val rolling = coefficients[0]
|
||||||
val aero = coefficients[1]
|
val aero = coefficients[1]
|
||||||
val thermal = if (features == 3) coefficients[2] else 0.0
|
val thermal = if (features == 3) coefficients[2] else 0.0
|
||||||
if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 || coefficients.any { !it.isFinite() }) {
|
when {
|
||||||
return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
|
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
|
var squaredError = 0.0
|
||||||
@@ -221,7 +251,7 @@ class SocConsumptionFitter(
|
|||||||
}
|
}
|
||||||
val rmse = sqrt(squaredError / segments.size)
|
val rmse = sqrt(squaredError / segments.size)
|
||||||
if (!rmse.isFinite() || rmse > maxResidualRmse) {
|
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(
|
val model = SocConsumptionModel(
|
||||||
@@ -233,11 +263,7 @@ class SocConsumptionFitter(
|
|||||||
segmentCount = segments.size,
|
segmentCount = segments.size,
|
||||||
envelope = EnergyModelEnvelope(minSpeed, maxSpeed, minTemp, maxTemp),
|
envelope = EnergyModelEnvelope(minSpeed, maxSpeed, minTemp, maxTemp),
|
||||||
)
|
)
|
||||||
return if (model.isValid()) {
|
return if (model.isValid()) Solved(model) else Solved(null, "invalid")
|
||||||
SocConsumptionFitResult.Ready(model)
|
|
||||||
} else {
|
|
||||||
SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -247,6 +273,11 @@ class SocConsumptionFitter(
|
|||||||
* refuses to build a segment from is most of what makes the fit trustworthy.
|
* refuses to build a segment from is most of what makes the fit trustworthy.
|
||||||
*/
|
*/
|
||||||
internal fun collect(samples: List<TripSample>, out: MutableList<Segment>) {
|
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 startSoc: Double? = null
|
||||||
var distanceKm = 0.0
|
var distanceKm = 0.0
|
||||||
var hours = 0.0
|
var hours = 0.0
|
||||||
@@ -302,13 +333,29 @@ class SocConsumptionFitter(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val drop = start - soc
|
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)
|
emit(distanceKm, hours, tempSum, tempCount, drop)?.let(out::add)
|
||||||
reset(soc)
|
reset(soc)
|
||||||
if (out.size >= maxSegments) return
|
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(
|
private fun emit(
|
||||||
distanceKm: Double,
|
distanceKm: Double,
|
||||||
hours: Double,
|
hours: Double,
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
<string name="group_comfort">Komfort</string>
|
<string name="group_comfort">Komfort</string>
|
||||||
<string name="group_audio">Audio</string>
|
<string name="group_audio">Audio</string>
|
||||||
<string name="group_adas">Fahrerassistenz</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_climate">Klima</string>
|
||||||
<string name="group_profile">Profil</string>
|
<string name="group_profile">Profil</string>
|
||||||
<string name="group_system">System</string>
|
<string name="group_system">System</string>
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
<string name="group_comfort">Confort</string>
|
<string name="group_comfort">Confort</string>
|
||||||
<string name="group_audio">Audio</string>
|
<string name="group_audio">Audio</string>
|
||||||
<string name="group_adas">Asistencia a la conducción</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_climate">Climatización</string>
|
||||||
<string name="group_profile">Perfil</string>
|
<string name="group_profile">Perfil</string>
|
||||||
<string name="group_system">Sistema</string>
|
<string name="group_system">Sistema</string>
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
<string name="group_comfort">Confort</string>
|
<string name="group_comfort">Confort</string>
|
||||||
<string name="group_audio">Audio</string>
|
<string name="group_audio">Audio</string>
|
||||||
<string name="group_adas">Aides à la conduite</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_climate">Climatisation</string>
|
||||||
<string name="group_profile">Profil</string>
|
<string name="group_profile">Profil</string>
|
||||||
<string name="group_system">Système</string>
|
<string name="group_system">Système</string>
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
<string name="group_comfort">Comfort</string>
|
<string name="group_comfort">Comfort</string>
|
||||||
<string name="group_audio">Audio</string>
|
<string name="group_audio">Audio</string>
|
||||||
<string name="group_adas">Assistenza alla guida</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_climate">Clima</string>
|
||||||
<string name="group_profile">Profilo</string>
|
<string name="group_profile">Profilo</string>
|
||||||
<string name="group_system">Sistema</string>
|
<string name="group_system">Sistema</string>
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
<string name="group_comfort">Conforto</string>
|
<string name="group_comfort">Conforto</string>
|
||||||
<string name="group_audio">Áudio</string>
|
<string name="group_audio">Áudio</string>
|
||||||
<string name="group_adas">Assistência à condução</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_climate">Climatização</string>
|
||||||
<string name="group_profile">Perfil</string>
|
<string name="group_profile">Perfil</string>
|
||||||
<string name="group_system">Sistema</string>
|
<string name="group_system">Sistema</string>
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
<string name="group_comfort">Comfort</string>
|
<string name="group_comfort">Comfort</string>
|
||||||
<string name="group_audio">Audio</string>
|
<string name="group_audio">Audio</string>
|
||||||
<string name="group_adas">Driver assistance</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_climate">Climate</string>
|
||||||
<string name="group_profile">Profile</string>
|
<string name="group_profile">Profile</string>
|
||||||
<string name="group_system">System</string>
|
<string name="group_system">System</string>
|
||||||
|
|||||||
@@ -127,13 +127,28 @@ class ReadFailureLogTest {
|
|||||||
|
|
||||||
class NeverPublishedCatalogueTest {
|
class NeverPublishedCatalogueTest {
|
||||||
|
|
||||||
@Test fun `SWI68 never publishes battery power or battery temperature`() {
|
@Test fun `SWI68 never publishes battery temperature`() {
|
||||||
CarPropertyEvidence.Signal.entries.forEach { signal ->
|
assertTrue(
|
||||||
assertTrue(
|
CarPropertyEvidence.isNeverPublished(
|
||||||
"$signal is proven absent on SWI68",
|
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
|
||||||
CarPropertyEvidence.isNeverPublished(signal, FirmwareInfo.Gen.SWI68),
|
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`() {
|
@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.
|
// never be validated. Both must be false-for-validated and true-for-absent at once.
|
||||||
assertFalse(
|
assertFalse(
|
||||||
CarPropertyEvidence.isValidated(
|
CarPropertyEvidence.isValidated(
|
||||||
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
|
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
|
||||||
FirmwareInfo.Gen.SWI68,
|
FirmwareInfo.Gen.SWI68,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
CarPropertyEvidence.isNeverPublished(
|
CarPropertyEvidence.isNeverPublished(
|
||||||
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
|
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
|
||||||
FirmwareInfo.Gen.SWI68,
|
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`() {
|
@Test fun `every R69 hardkey code decodes`() {
|
||||||
val expected = mapOf(
|
val expected = mapOf(
|
||||||
5 to PhysicalButtonEventDecoder.Button.PHONE,
|
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,
|
23 to PhysicalButtonEventDecoder.Button.CENTER,
|
||||||
24 to PhysicalButtonEventDecoder.Button.VOLUME_UP,
|
24 to PhysicalButtonEventDecoder.Button.VOLUME_UP,
|
||||||
25 to PhysicalButtonEventDecoder.Button.VOLUME_DOWN,
|
25 to PhysicalButtonEventDecoder.Button.VOLUME_DOWN,
|
||||||
@@ -32,6 +26,72 @@ class PhysicalButtonEventDecoderTest {
|
|||||||
assertEquals(button, decoder.accept(code, false, false)?.button)
|
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`() {
|
@Test fun `two releases inside the window make one double`() {
|
||||||
val decoder = PhysicalButtonEventDecoder()
|
val decoder = PhysicalButtonEventDecoder()
|
||||||
decoder.accept(17, true, false, atMillis = 0)
|
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_ELK_MODE, ActionType.SET_ELK_SENSITIVITY,
|
||||||
ActionType.SET_ACC_TJA_MODE, ActionType.SET_LIMITER_MODE,
|
ActionType.SET_ACC_TJA_MODE, ActionType.SET_LIMITER_MODE,
|
||||||
ActionType.SET_TSR, ActionType.SET_OVERSPEED_ALARM,
|
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 ->
|
mustBeGated.forEach { type ->
|
||||||
@@ -325,7 +326,15 @@ class CatalogConsistencyTest {
|
|||||||
}
|
}
|
||||||
// Exactly these: an unproven write is invisible to the user, so adding one elsewhere
|
// 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.
|
// 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
|
@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
|
val calls = RuntimeInterfaceProbe.CONFIG_CALLS
|
||||||
assertEquals(calls.size, calls.values.map { it.code }.toSet().size)
|
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)))
|
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`() {
|
@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 store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
|
||||||
val recorder = BatteryLedgerRecorder(store)
|
val recorder = BatteryLedgerRecorder(store)
|
||||||
@@ -134,7 +144,7 @@ class BatteryLedgerTest {
|
|||||||
consumedKwh: Float? = null,
|
consumedKwh: Float? = null,
|
||||||
regeneratedKwh: Float? = null,
|
regeneratedKwh: Float? = null,
|
||||||
) = EnergySnapshot(
|
) = EnergySnapshot(
|
||||||
timestampMs = atMs,
|
timestampMs = NOW_MS + atMs,
|
||||||
firmware = FirmwareInfo.Gen.SWI68,
|
firmware = FirmwareInfo.Gen.SWI68,
|
||||||
socPercent = soc,
|
socPercent = soc,
|
||||||
rangeKm = null,
|
rangeKm = null,
|
||||||
@@ -156,4 +166,9 @@ class BatteryLedgerTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
private fun tempDirectory(): File = Files.createTempDirectory("ledger").toFile()
|
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)
|
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`() {
|
@Test fun `candidate probes exist only in the explicit evidence read`() {
|
||||||
val source = FakeSignals(soc = 80f, range = 200f, power = 18f)
|
val source = FakeSignals(soc = 80f, range = 200f, power = 18f)
|
||||||
source.probes = listOf(
|
source.probes = listOf(
|
||||||
@@ -70,6 +81,50 @@ class EnergyTelemetryTest {
|
|||||||
assertEquals(0.001666, result.regeneratedKwh!!, 0.000001)
|
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`() {
|
@Test fun `trip keeps distance unknown without a usable speed interval`() {
|
||||||
val trip = EnergyTripAccumulator(0L, 80f)
|
val trip = EnergyTripAccumulator(0L, 80f)
|
||||||
trip.add(snapshot(0L, null, 18f))
|
trip.add(snapshot(0L, null, 18f))
|
||||||
@@ -124,6 +179,10 @@ class EnergyTelemetryTest {
|
|||||||
private val power: Float?,
|
private val power: Float?,
|
||||||
) : EnergySignalSource {
|
) : EnergySignalSource {
|
||||||
var probes: List<TelemetryEvidenceProbe> = emptyList()
|
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 firmware() = FirmwareInfo.Gen.SWI68
|
||||||
override fun socPercent() = soc
|
override fun socPercent() = soc
|
||||||
override fun rangeKm() = range
|
override fun rangeKm() = range
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ class TelemetryEvidenceTest {
|
|||||||
assertEquals(1, parked.samples)
|
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`() {
|
@Test fun `the update period follows value changes, not sampling ticks`() {
|
||||||
val recorder = TelemetryEvidenceRecorder()
|
val recorder = TelemetryEvidenceRecorder()
|
||||||
// One second between reads, five seconds between the values the vehicle publishes.
|
// 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