diff --git a/build.gradle.kts b/build.gradle.kts
index 13974dc..2972ddd 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -23,7 +23,7 @@ tasks.register<Delete>("clean") {
 // =============================================================================
 
 val releaseDir = layout.projectDirectory.dir("release")
-val versionName = "1.0.0-beta4"
+val versionName = "1.0.0-beta8"
 
 val syncDemoLibs by tasks.registering {
     group = "qsmw"
@@ -136,9 +136,64 @@ val packReleaseInfo by tasks.registering {
 
 val copyReleaseStaticFiles by tasks.registering(Copy::class) {
     group = "qsmw"
-    description = "Copy static template files (README.md etc.) into release/"
+    description = "Copy static template files (API_REFERENCE.md etc.) into release/"
     from(layout.projectDirectory.dir("release-template"))
     into(releaseDir)
+    // README.md is generated dynamically from actual artifact sizes / hashes.
+    exclude("README.md")
+}
+
+val generateGitPatch by tasks.registering {
+    group = "qsmw"
+    description = "Generate git patch (diff) from last tag to HEAD"
+    dependsOn(copyReleaseArtifacts)
+    val patchFile = releaseDir.file("changes.patch").asFile
+    outputs.file(patchFile)
+    doLast {
+        val latestTag = try {
+            val stdout = java.io.ByteArrayOutputStream()
+            exec {
+                workingDir = rootDir
+                commandLine("git", "describe", "--tags", "--abbrev=0")
+                standardOutput = stdout
+                errorOutput = java.io.ByteArrayOutputStream()
+                isIgnoreExitValue = true
+            }
+            stdout.toString().trim()
+        } catch (e: Exception) {
+            ""
+        }
+        if (latestTag.isEmpty()) {
+            patchFile.writeText("# No previous git tag found; cannot generate patch.\n")
+            println("WARNING: No previous git tag found, skipping patch generation")
+            return@doLast
+        }
+        patchFile.outputStream().use { out ->
+            exec {
+                workingDir = rootDir
+                commandLine(
+                    "git", "diff", "--binary", "$latestTag..HEAD",
+                    "--", "service/", "contract/", "sdk/", "build.gradle.kts",
+                    "gradle/", "scripts/"
+                )
+                standardOutput = out
+                isIgnoreExitValue = true
+            }
+        }
+        println("Generated ${patchFile.absolutePath} ($latestTag..HEAD)")
+    }
+}
+
+val generateReleaseReadme by tasks.registering(Exec::class) {
+    group = "qsmw"
+    description = "Generate release/README.md from actual artifact sizes and hashes"
+    dependsOn(packReleaseInfo, copyReleaseStaticFiles, generateGitPatch)
+    inputs.dir(releaseDir)
+    outputs.file(releaseDir.file("README.md"))
+    val script = layout.projectDirectory.file("scripts/generate_release_readme.py").asFile
+    val releaseDirPath = releaseDir.asFile.absolutePath
+    executable("python3")
+    args(script.absolutePath, releaseDirPath, versionName)
 }
 
 val packRelease by tasks.registering {
@@ -149,7 +204,9 @@ val packRelease by tasks.registering {
         syncDemoLibs,
         packDemos,
         packReleaseInfo,
-        copyReleaseStaticFiles
+        copyReleaseStaticFiles,
+        generateGitPatch,
+        generateReleaseReadme
     )
     doLast {
         println("\n=== Release packaging done ===")
@@ -159,5 +216,31 @@ val packRelease by tasks.registering {
             ?.forEach { f ->
                 println("  ${f.name}  (${"%,d".format(f.length())} B)")
             }
+
+        // Auto-tag on successful packaging
+        val tagName = "v$versionName"
+        val tagExists = try {
+            val stdout = java.io.ByteArrayOutputStream()
+            exec {
+                workingDir = rootDir
+                commandLine("git", "tag", "-l", tagName)
+                standardOutput = stdout
+                isIgnoreExitValue = true
+            }
+            stdout.toString().trim() == tagName
+        } catch (e: Exception) {
+            false
+        }
+        if (tagExists) {
+            println("\n[SKIP] Git tag '$tagName' already exists.")
+        } else {
+            exec {
+                workingDir = rootDir
+                commandLine("git", "tag", "-a", tagName, "-m", "Release $tagName")
+                isIgnoreExitValue = true
+            }
+            println("\n[OK] Created git tag '$tagName'.")
+        }
+        println("[HINT] Push tag: git push origin $tagName")
     }
 }
diff --git a/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuKeyListener.aidl b/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuKeyListener.aidl
new file mode 100644
index 0000000..f0b3b5a
--- /dev/null
+++ b/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuKeyListener.aidl
@@ -0,0 +1,6 @@
+package com.qsjnic.mcu.contract;
+
+interface IMcuKeyListener {
+    /** Key 0x01: full key-domain event. */
+    void onKeyEvent(int keyValue, int keyStatus);
+}
diff --git a/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuService.aidl b/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuService.aidl
index 6ee85ec..2eca439 100644
--- a/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuService.aidl
+++ b/contract/src/main/aidl/com/qsjnic/mcu/contract/IMcuService.aidl
@@ -7,6 +7,7 @@
 package com.qsjnic.mcu.contract;
 
 import com.qsjnic.mcu.contract.IMcuVehicleListener;
+import com.qsjnic.mcu.contract.IMcuKeyListener;
 import com.qsjnic.mcu.contract.IMcuRadioListener;
 import com.qsjnic.mcu.contract.IMcuUpgradeListener;
 import com.qsjnic.mcu.contract.VehicleSnapshot;
@@ -207,6 +208,29 @@ interface IMcuService {
 
     // ===== Meta / introspection (added in beta4, AIDL surface remains v2) =====
 
-    /** Returns the Service-side build version (e.g. "1.0.0-beta4"). */
+    /** Returns the Service-side build version (e.g. "1.0.0-beta5"). */
     String getServiceVersion();
+
+    // ===== Protocol-correct extensions (added after beta6 audit; append-only) =====
+
+    /** Radio 0x83 SET_BAND with current frequency payload. */
+    int setBandWithFreq(int band, int freq);
+
+    /** Update 0x86: send boot update 16-byte MD5 checksum. */
+    int sendBootUpgradeChecksumMd5(in byte[] md5);
+
+    /** Public 0x91: request trip meter clear. type: 1=long trip, 2=total, 3=short trip. */
+    int requestTripClearType(int type);
+
+    /** Public 0x94: send full protocol GPS payload. */
+    int setGpsInfoFull(int moduleStatus, int lonPpm, int lonStatus, int latPpm, int latStatus);
+
+    // ===== Key domain (added after beta6 audit; append-only) =====
+
+    /** Subscribe to Key 0x02 domain key events. */
+    void registerKeyListener(in IMcuKeyListener cb);
+    void unregisterKeyListener(in IMcuKeyListener cb);
+
+    /** Key 0x81: send key value/status to MCU. */
+    int sendKeyEvent(int keyValue, int keyStatus);
 }
diff --git a/contract/src/main/java/com/qsjnic/mcu/contract/McuServiceManager.java b/contract/src/main/java/com/qsjnic/mcu/contract/McuServiceManager.java
index 46c001c..ae3a15e 100644
--- a/contract/src/main/java/com/qsjnic/mcu/contract/McuServiceManager.java
+++ b/contract/src/main/java/com/qsjnic/mcu/contract/McuServiceManager.java
@@ -24,7 +24,7 @@ public final class McuServiceManager {
     /** SDK/Service build version string. Read via {@link com.qsjnic.mcu.sdk.McuClient#getSdkVersion()}
      *  without an IPC round-trip, or via the bound Service's
      *  {@code IMcuService.getServiceVersion()} for the live binding's version. */
-    public static final String SDK_VERSION = "1.0.0-beta4";
+    public static final String SDK_VERSION = "1.0.0-beta7";
 
     /** Current AIDL surface version. Bumped on breaking changes. */
     public static final int AIDL_VERSION = 2;
diff --git a/contract/src/main/java/com/qsjnic/mcu/domain/Cmds.java b/contract/src/main/java/com/qsjnic/mcu/domain/Cmds.java
index 95a588e..e815cf5 100644
--- a/contract/src/main/java/com/qsjnic/mcu/domain/Cmds.java
+++ b/contract/src/main/java/com/qsjnic/mcu/domain/Cmds.java
@@ -79,11 +79,21 @@ public final class Cmds {
 
         // M2A
         public static final int M2A_FREQ        = 0x01;
+        public static final int M2A_PS_PTY      = 0x02;
+        public static final int M2A_RT          = 0x03;
         public static final int M2A_TA_PTY_TIPS = 0x04;
+        public static final int M2A_WORK_STATUS = 0x05;
 
         private Radio() {}
     }
 
+    public static final class Key {
+        public static final int M2A_KEY = 0x01;
+        public static final int A2M_KEY = 0x81;
+
+        private Key() {}
+    }
+
     public static final class Update {
         // A2M
         public static final int REQ        = 0x80;
diff --git a/contract/src/main/java/com/qsjnic/mcu/domain/Commands.java b/contract/src/main/java/com/qsjnic/mcu/domain/Commands.java
index a1e6ee0..b748f67 100644
--- a/contract/src/main/java/com/qsjnic/mcu/domain/Commands.java
+++ b/contract/src/main/java/com/qsjnic/mcu/domain/Commands.java
@@ -30,7 +30,6 @@ public final class Commands {
             require(minute >= 0 && minute <= 59, "minute out of range");
             require(second >= 0 && second <= 59, "second out of range");
             return new byte[] {
-                    0x01,
                     (byte) (year - 2000), (byte) month, (byte) day,
                     (byte) hour, (byte) minute, (byte) second
             };
@@ -95,11 +94,16 @@ public final class Commands {
         }
 
         public static byte[] reqTripClear() {
-            return EMPTY;
+            return reqTripClear(1);
+        }
+
+        public static byte[] reqTripClear(int type) {
+            require(type >= 1 && type <= 3, "type out of range");
+            return new byte[] { (byte) type };
         }
 
         public static byte[] switchStatus(int switchBitmap) {
-            require(switchBitmap >= 0 && switchBitmap <= 0xFFFF, "bitmap out of range");
+            require(switchBitmap >= 0 && switchBitmap <= 0xFF, "bitmap out of range");
             return new byte[] { (byte) (switchBitmap & 0xFF) };
         }
 
@@ -109,10 +113,26 @@ public final class Commands {
         }
 
         public static byte[] gpsInfo(int latE6, int lonE6, int speedKmh, int heading) {
+            int lonStatus = (lonE6 < 0 ? 0x02 : 0x00) | (lonE6 == 0 && latE6 == 0 ? 0x00 : 0x01);
+            int latStatus = (latE6 < 0 ? 0x02 : 0x00) | (lonE6 == 0 && latE6 == 0 ? 0x00 : 0x01);
+            return gpsInfoFull(0, Math.abs(lonE6), lonStatus, Math.abs(latE6), latStatus);
+        }
+
+        public static byte[] gpsInfoFull(int moduleStatus, int lonPpm, int lonStatus,
+                                         int latPpm, int latStatus) {
+            require(moduleStatus >= 0 && moduleStatus <= 0xFF, "moduleStatus out of range");
+            require(lonPpm >= 0, "lonPpm must be non-negative");
+            require(lonStatus >= 0 && lonStatus <= 0xFF, "lonStatus out of range");
+            require(latPpm >= 0, "latPpm must be non-negative");
+            require(latStatus >= 0 && latStatus <= 0xFF, "latStatus out of range");
             return new byte[] {
-                    (byte) ((latE6 >> 16) & 0xFF), (byte) ((latE6 >> 8) & 0xFF), (byte) (latE6 & 0xFF),
-                    (byte) ((lonE6 >> 16) & 0xFF), (byte) ((lonE6 >> 8) & 0xFF), (byte) (lonE6 & 0xFF),
-                    (byte) speedKmh, (byte) heading
+                    (byte) moduleStatus,
+                    (byte) ((lonPpm >> 24) & 0xFF), (byte) ((lonPpm >> 16) & 0xFF),
+                    (byte) ((lonPpm >> 8) & 0xFF),  (byte) (lonPpm & 0xFF),
+                    (byte) lonStatus,
+                    (byte) ((latPpm >> 24) & 0xFF), (byte) ((latPpm >> 16) & 0xFF),
+                    (byte) ((latPpm >> 8) & 0xFF),  (byte) (latPpm & 0xFF),
+                    (byte) latStatus
             };
         }
 
@@ -130,10 +150,12 @@ public final class Commands {
         }
 
         public static byte[] fuelCal(byte[] data) {
+            require(data == null || data.length == 2, "fuelCal payload must be 2 bytes");
             return data == null ? EMPTY : data;
         }
 
         public static byte[] vehicleCfg(byte[] data) {
+            require(data == null || data.length == 16, "vehicleCfg payload must be 16 bytes");
             return data == null ? EMPTY : data;
         }
 
@@ -143,12 +165,12 @@ public final class Commands {
 
         public static byte[] armSwVersion(String version) {
             require(version != null, "version is null");
-            return version.getBytes(StandardCharsets.UTF_8);
+            return versionPayload(version);
         }
 
         public static byte[] armHwVersion(String version) {
             require(version != null, "version is null");
-            return version.getBytes(StandardCharsets.UTF_8);
+            return versionPayload(version);
         }
 
         public static byte[] autoClearMeterType(int type) {
@@ -170,8 +192,13 @@ public final class Commands {
     public static final class Radio {
 
         public static byte[] setBand(int band) {
+            return setBand(band, 0);
+        }
+
+        public static byte[] setBand(int band, int freq) {
             require(band >= 0 && band <= 1, "band out of range");
-            return new byte[] { (byte) band };
+            require(freq >= 0 && freq <= 0xFFFF, "freq out of range");
+            return new byte[] { (byte) band, (byte) ((freq >> 8) & 0xFF), (byte) (freq & 0xFF) };
         }
 
         public static byte[] setFrequency(int freq) {
@@ -212,12 +239,35 @@ public final class Commands {
         private Radio() {}
     }
 
+    public static final class Key {
+
+        public static byte[] key(int keyValue, int keyStatus) {
+            require(keyValue >= 0 && keyValue <= 0x25, "keyValue out of range (Pajero 0x00-0x25)");
+            require(keyStatus >= 0 && keyStatus <= 0x04, "keyStatus out of range");
+            return new byte[] { (byte) keyValue, (byte) keyStatus };
+        }
+
+        private Key() {}
+    }
+
     public static final class Update {
 
         public static byte[] bootBegin() {
             return EMPTY;
         }
 
+        public static byte[] bootBegin(long binSize, String projectId) {
+            require(binSize >= 0 && binSize <= 0xFFFFFFFFL, "binSize out of range");
+            byte[] project = projectBytes(projectId);
+            byte[] payload = new byte[4 + project.length];
+            payload[0] = (byte) ((binSize >> 24) & 0xFF);
+            payload[1] = (byte) ((binSize >> 16) & 0xFF);
+            payload[2] = (byte) ((binSize >> 8) & 0xFF);
+            payload[3] = (byte) (binSize & 0xFF);
+            System.arraycopy(project, 0, payload, 4, project.length);
+            return payload;
+        }
+
         public static byte[] bootData(byte[] chunk, int index) {
             require(chunk != null, "chunk is null");
             byte[] payload = new byte[2 + chunk.length];
@@ -234,6 +284,13 @@ public final class Commands {
             };
         }
 
+        public static byte[] bootChecksumMd5(byte[] md5) {
+            require(md5 != null && md5.length == 16, "md5 must be 16 bytes");
+            byte[] payload = new byte[16];
+            System.arraycopy(md5, 0, payload, 0, 16);
+            return payload;
+        }
+
         private Update() {}
     }
 
@@ -243,5 +300,20 @@ public final class Commands {
         if (!cond) throw new IllegalArgumentException(msg);
     }
 
+    private static byte[] versionPayload(String version) {
+        byte[] text = version.getBytes(StandardCharsets.UTF_8);
+        require(text.length <= 0xFF, "version too long");
+        byte[] payload = new byte[1 + text.length];
+        payload[0] = (byte) text.length;
+        System.arraycopy(text, 0, payload, 1, text.length);
+        return payload;
+    }
+
+    private static byte[] projectBytes(String projectId) {
+        String project = projectId == null ? "" : projectId;
+        require(project.length() <= 5, "projectId too long");
+        return project.getBytes(StandardCharsets.US_ASCII);
+    }
+
     private Commands() {}
 }
diff --git a/contract/src/main/java/com/qsjnic/mcu/domain/Decoders.java b/contract/src/main/java/com/qsjnic/mcu/domain/Decoders.java
index 6ed33f3..cfd3127 100644
--- a/contract/src/main/java/com/qsjnic/mcu/domain/Decoders.java
+++ b/contract/src/main/java/com/qsjnic/mcu/domain/Decoders.java
@@ -18,7 +18,8 @@ public final class Decoders {
 
     public static int parseVoltage(byte[] body) {
         if (body == null || body.length < 2) return -1;
-        return ((body[0] & 0xFF) << 8) | (body[1] & 0xFF);
+        int tenthVolt = ((body[0] & 0xFF) << 8) | (body[1] & 0xFF);
+        return tenthVolt * 100;
     }
 
     public static int parseSysStatus(byte[] body) {
@@ -42,14 +43,15 @@ public final class Decoders {
     }
 
     public static int parseCarSpeed(byte[] body) {
-        if (body == null || body.length < 1) return -1;
-        return body[0] & 0xFF;
+        if (body == null || body.length < 2) return -1;
+        return ((body[0] & 0xFF) << 8) | (body[1] & 0xFF);
     }
 
     public static int parseMileage(byte[] body) {
         if (body == null || body.length < 4) return -1;
-        return ((body[0] & 0xFF) << 24) | ((body[1] & 0xFF) << 16)
-             | ((body[2] & 0xFF) << 8)  | (body[3] & 0xFF);
+        int offset = body.length >= 8 ? 4 : 0;
+        return ((body[offset] & 0xFF) << 24) | ((body[offset + 1] & 0xFF) << 16)
+             | ((body[offset + 2] & 0xFF) << 8)  | (body[offset + 3] & 0xFF);
     }
 
     public static int parseSwitchStatus(byte[] body) {
@@ -68,13 +70,12 @@ public final class Decoders {
     }
 
     public static int parseBattVoltage(byte[] body) {
-        if (body == null || body.length < 2) return -1;
-        return ((body[0] & 0xFF) << 8) | (body[1] & 0xFF);
+        return -1;
     }
 
     public static int parseBattPercent(byte[] body) {
-        if (body == null || body.length < 3) return -1;
-        return body[2] & 0xFF;
+        if (body == null || body.length < 1) return -1;
+        return body[0] & 0xFF;
     }
 
     /** Returns [isSet, year(2000+), month, day, hour, minute, second] or null. */
@@ -113,6 +114,27 @@ public final class Decoders {
 
     // ------------------------------------------------------------ Radio
 
+    /** Returns [pi, pty, ps] or null. Protocol carries PTY + 8-byte PS; pi is kept as 0 for AIDL compatibility. */
+    public static Object[] parsePsPty(byte[] body) {
+        if (body == null || body.length < 1) return null;
+        int pi  = 0;
+        int pty = body[0] & 0xFF;
+        String ps = body.length > 1 ? trimUtf8(body, 1, body.length - 1) : "";
+        return new Object[] { pi, pty, ps };
+    }
+
+    /** Returns RT (Radio Text) string, or empty string. */
+    public static String parseRadioText(byte[] body) {
+        if (body == null || body.length == 0) return "";
+        return trimUtf8(body, 0, body.length);
+    }
+
+    /** Returns work status code, or -1. */
+    public static int parseWorkStatus(byte[] body) {
+        if (body == null || body.length < 1) return -1;
+        return body[0] & 0xFF;
+    }
+
     public static RadioFreq parseRadioFreq(byte[] body) {
         if (body == null || body.length < 7) return null;
         RadioFreq f = new RadioFreq();
@@ -122,6 +144,14 @@ public final class Decoders {
         return f;
     }
 
+    // ------------------------------------------------------------ Key
+
+    /** Returns [keyValue, keyStatus] or null. */
+    public static int[] parseKeyControl(byte[] body) {
+        if (body == null || body.length < 2) return null;
+        return new int[] { body[0] & 0xFF, body[1] & 0xFF };
+    }
+
     /** Returns [mode, value] or null. */
     public static int[] parseTaPtyTips(byte[] body) {
         if (body == null || body.length < 1) return null;
diff --git a/contract/src/main/java/com/qsjnic/mcu/domain/MsgType.java b/contract/src/main/java/com/qsjnic/mcu/domain/MsgType.java
index eb2c16a..6c738da 100644
--- a/contract/src/main/java/com/qsjnic/mcu/domain/MsgType.java
+++ b/contract/src/main/java/com/qsjnic/mcu/domain/MsgType.java
@@ -3,6 +3,7 @@ package com.qsjnic.mcu.domain;
 /** Message type constants (module ID). */
 public final class MsgType {
     public static final int PUBLIC = 0x01;
+    public static final int KEY    = 0x02;
     public static final int RADIO  = 0x05;
     public static final int UPDATE = 0x07;
 
diff --git a/contract/src/test/java/com/qsjnic/mcu/domain/CommandsMoreTest.java b/contract/src/test/java/com/qsjnic/mcu/domain/CommandsMoreTest.java
index dfa1a78..3d4ed51 100644
--- a/contract/src/test/java/com/qsjnic/mcu/domain/CommandsMoreTest.java
+++ b/contract/src/test/java/com/qsjnic/mcu/domain/CommandsMoreTest.java
@@ -53,11 +53,11 @@ public class CommandsMoreTest {
     }
 
     @Test public void setBand_fm() {
-        assertArrayEquals(new byte[]{0}, Commands.Radio.setBand(0));
+        assertArrayEquals(new byte[]{0, 0, 0}, Commands.Radio.setBand(0));
     }
 
     @Test public void setBand_am() {
-        assertArrayEquals(new byte[]{1}, Commands.Radio.setBand(1));
+        assertArrayEquals(new byte[]{1, 0, 0}, Commands.Radio.setBand(1));
     }
 
     @Test public void setFrequency_0() {
diff --git a/contract/src/test/java/com/qsjnic/mcu/domain/CommandsTest.java b/contract/src/test/java/com/qsjnic/mcu/domain/CommandsTest.java
index 2350266..1929472 100644
--- a/contract/src/test/java/com/qsjnic/mcu/domain/CommandsTest.java
+++ b/contract/src/test/java/com/qsjnic/mcu/domain/CommandsTest.java
@@ -26,7 +26,7 @@ public class CommandsTest {
     @Test
     public void timeInfo_basic() {
         assertArrayEquals(
-                new byte[] { 1, 26, 5, 16, 14, 30, 0 },
+                new byte[] { 26, 5, 16, 14, 30, 0 },
                 Commands.Public.timeInfo(2026, 5, 16, 14, 30, 0));
     }
 
@@ -133,7 +133,12 @@ public class CommandsTest {
 
     @Test
     public void reqTripClear_returnsEmpty() {
-        assertArrayEquals(new byte[0], Commands.Public.reqTripClear());
+        assertArrayEquals(new byte[] { 1 }, Commands.Public.reqTripClear());
+    }
+
+    @Test
+    public void reqTripClear_type() {
+        assertArrayEquals(new byte[] { 3 }, Commands.Public.reqTripClear(3));
     }
 
     @Test
@@ -150,9 +155,11 @@ public class CommandsTest {
     public void gpsInfo() {
         assertArrayEquals(
                 new byte[] {
-                        0x12, 0x34, 0x56,   // latE6 = 0x123456
-                        0x78, (byte)0x9A, (byte)0xBC, // lonE6 = 0x789ABC
-                        60, (byte)180
+                        0x00,
+                        0x00, 0x78, (byte)0x9A, (byte)0xBC,
+                        0x01,
+                        0x00, 0x12, 0x34, 0x56,
+                        0x01
                 },
                 Commands.Public.gpsInfo(0x123456, 0x789ABC, 60, 180));
     }
@@ -181,7 +188,7 @@ public class CommandsTest {
 
     @Test
     public void vehicleCfg_raw() {
-        byte[] raw = new byte[] { 1, 2, 3 };
+        byte[] raw = new byte[16];
         assertSame(raw, Commands.Public.vehicleCfg(raw));
     }
 
@@ -192,7 +199,7 @@ public class CommandsTest {
 
     @Test
     public void armSwVersion() {
-        byte[] expected = "v1.2.3".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+        byte[] expected = new byte[] { 6, 'v', '1', '.', '2', '.', '3' };
         assertArrayEquals(expected, Commands.Public.armSwVersion("v1.2.3"));
     }
 
@@ -203,7 +210,7 @@ public class CommandsTest {
 
     @Test
     public void armHwVersion() {
-        byte[] expected = "HW-A".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+        byte[] expected = new byte[] { 4, 'H', 'W', '-', 'A' };
         assertArrayEquals(expected, Commands.Public.armHwVersion("HW-A"));
     }
 
@@ -227,12 +234,17 @@ public class CommandsTest {
 
     @Test
     public void setBand_fm() {
-        assertArrayEquals(new byte[] { 0 }, Commands.Radio.setBand(0));
+        assertArrayEquals(new byte[] { 0, 0, 0 }, Commands.Radio.setBand(0));
     }
 
     @Test
     public void setBand_am() {
-        assertArrayEquals(new byte[] { 1 }, Commands.Radio.setBand(1));
+        assertArrayEquals(new byte[] { 1, 0, 0 }, Commands.Radio.setBand(1));
+    }
+
+    @Test
+    public void setBand_withFreq() {
+        assertArrayEquals(new byte[] { 0, 0x22, 0x2E }, Commands.Radio.setBand(0, 0x222E));
     }
 
     @Test(expected = IllegalArgumentException.class)
@@ -245,6 +257,23 @@ public class CommandsTest {
         assertArrayEquals(new byte[] { 0x22, 0x2E }, Commands.Radio.setFrequency(0x222E));
     }
 
+    // ---- Key ----
+
+    @Test
+    public void keyDomainEvent() {
+        assertArrayEquals(new byte[] { 0x01, 0x01 }, Commands.Key.key(1, 1));
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void keyDomainEvent_valueTooHigh() {
+        Commands.Key.key(0x0C, 1);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void keyDomainEvent_statusTooHigh() {
+        Commands.Key.key(1, 0x05);
+    }
+
     @Test
     public void setFreqRange() {
         assertArrayEquals(
@@ -279,6 +308,13 @@ public class CommandsTest {
         assertArrayEquals(new byte[0], Commands.Update.bootBegin());
     }
 
+    @Test
+    public void bootBegin_protocolPayload() {
+        assertArrayEquals(
+                new byte[] { 0, 0, 0x10, 0x00, '1', '0', '0', '4', '0' },
+                Commands.Update.bootBegin(0x1000, "10040"));
+    }
+
     @Test
     public void bootData() {
         byte[] chunk = new byte[] { 0x0A, 0x0B };
@@ -298,4 +334,11 @@ public class CommandsTest {
                 new byte[] { 0x12, 0x34, 0x56, 0x78 },
                 Commands.Update.bootChecksum(0x12345678));
     }
+
+    @Test
+    public void bootChecksumMd5() {
+        byte[] md5 = new byte[16];
+        md5[15] = 1;
+        assertArrayEquals(md5, Commands.Update.bootChecksumMd5(md5));
+    }
 }
diff --git a/contract/src/test/java/com/qsjnic/mcu/domain/DecodersTest.java b/contract/src/test/java/com/qsjnic/mcu/domain/DecodersTest.java
index 8222b3c..e499c9c 100644
--- a/contract/src/test/java/com/qsjnic/mcu/domain/DecodersTest.java
+++ b/contract/src/test/java/com/qsjnic/mcu/domain/DecodersTest.java
@@ -34,7 +34,7 @@ public class DecodersTest {
 
     @Test
     public void parseVoltage_basic() {
-        assertEquals(0x30D4, Decoders.parseVoltage(new byte[] { 0x30, (byte)0xD4 }));
+        assertEquals(12800, Decoders.parseVoltage(new byte[] { 0x00, (byte)0x80 }));
     }
 
     @Test
@@ -59,12 +59,14 @@ public class DecodersTest {
 
     @Test
     public void parseCarSpeed() {
-        assertEquals(60, Decoders.parseCarSpeed(new byte[] { 60 }));
+        assertEquals(60, Decoders.parseCarSpeed(new byte[] { 0, 60, 100 }));
     }
 
     @Test
     public void parseMileage() {
-        assertEquals(0x12345678, Decoders.parseMileage(new byte[] { 0x12, 0x34, 0x56, 0x78 }));
+        assertEquals(0x01020304, Decoders.parseMileage(new byte[] {
+                0x12, 0x34, 0x56, 0x78, 0x01, 0x02, 0x03, 0x04
+        }));
     }
 
     @Test
@@ -143,4 +145,26 @@ public class DecodersTest {
         assertArrayEquals(in, out);
         assertNotSame(in, out);
     }
+
+    @Test
+    public void parsePsPty_protocolShape() {
+        Object[] v = Decoders.parsePsPty(new byte[] { 5, 'P', 'S', 0, 0, 0, 0, 0, 0 });
+        assertNotNull(v);
+        assertEquals(0, v[0]);
+        assertEquals(5, v[1]);
+        assertEquals("PS", v[2]);
+    }
+
+    @Test
+    public void parseKeyControl() {
+        int[] v = Decoders.parseKeyControl(new byte[] { 0x0A, 0x01 });
+        assertNotNull(v);
+        assertEquals(0x0A, v[0]);
+        assertEquals(0x01, v[1]);
+    }
+
+    @Test
+    public void parseKeyControl_tooShort() {
+        assertNull(Decoders.parseKeyControl(new byte[] { 0x01 }));
+    }
 }
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 273885b..636c9cc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
 [versions]
-agp = "8.2.2"
+agp = "8.8.2"
 kotlin = "1.9.22"
 
 # Android SDK / Java
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 3fa8f86..df97d72 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
 networkTimeout=10000
 validateDistributionUrl=true
 zipStoreBase=GRADLE_USER_HOME
diff --git a/scripts/deploy_beta6_to_target.sh b/scripts/deploy_beta6_to_target.sh
new file mode 100755
index 0000000..afb7dcd
--- /dev/null
+++ b/scripts/deploy_beta6_to_target.sh
@@ -0,0 +1,157 @@
+#!/bin/bash
+#
+# QSJNIC v1.0.0-beta6 车机部署与验证脚本
+# 适用: 支持 adb root + remount 的 userdebug/eng 车机
+# 目标 IP: 192.168.0.66
+#
+
+set -euo pipefail
+
+TARGET_IP="192.168.0.66"
+RELEASE_DIR="$(cd "$(dirname "$0")/../release/v1.0.0-beta6" && pwd)"
+SERVICE_APK="${RELEASE_DIR}/qsmw-service.apk"
+DEMO_OTA="${RELEASE_DIR}/qsmw-ota-demo.apk"
+DEMO_RADIO="${RELEASE_DIR}/qsmw-radio-demo.apk"
+DEMO_VEHICLE="${RELEASE_DIR}/qsmw-vehicle-demo.apk"
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+info()  { echo -e "${GREEN}[INFO]${NC} $*"; }
+warn()  { echo -e "${YELLOW}[WARN]${NC} $*"; }
+error() { echo -e "${RED}[ERROR]${NC} $*"; }
+
+# ----------------------- 前置检查 -----------------------
+info "检查 release 产物..."
+for f in "$SERVICE_APK" "$DEMO_OTA" "$DEMO_RADIO" "$DEMO_VEHICLE"; do
+    if [[ ! -f "$f" ]]; then
+        error "文件不存在: $f"
+        exit 1
+    fi
+done
+info "release 产物齐全"
+
+info "连接车机 ${TARGET_IP}..."
+adb connect "${TARGET_IP}" >/dev/null 2>&1 || true
+adb -s "${TARGET_IP}:5555" wait-for-device
+
+DEVICE_STATUS=$(adb -s "${TARGET_IP}:5555" get-state)
+if [[ "$DEVICE_STATUS" != "device" ]]; then
+    error "adb 状态异常: ${DEVICE_STATUS}"
+    exit 1
+fi
+info "adb 已连接: ${TARGET_IP}"
+
+# ----------------------- 提权 & remount -----------------------
+info "执行 adb root..."
+adb -s "${TARGET_IP}:5555" root >/dev/null 2>&1
+sleep 2
+adb -s "${TARGET_IP}:5555" wait-for-device
+
+info "执行 adb remount..."
+if ! adb -s "${TARGET_IP}:5555" remount >/dev/null 2>&1; then
+    error "remount 失败，该车机可能不支持写入 /system 分区"
+    error "请确认是 userdebug/eng 版本，或已执行 'adb disable-verity' 并重启"
+    exit 1
+fi
+info "remount 成功"
+
+# ----------------------- 部署 Service -----------------------
+info "部署 qsmw-service.apk 到 /system/priv-app/QsmwService/..."
+adb -s "${TARGET_IP}:5555" shell "mkdir -p /system/priv-app/QsmwService"
+
+# 如果已存在旧版本，先备份（可选）
+OLD_APK=$(adb -s "${TARGET_IP}:5555" shell "ls /system/priv-app/QsmwService/*.apk 2>/dev/null" | tr -d '\r')
+if [[ -n "$OLD_APK" ]]; then
+    warn "发现旧版本: ${OLD_APK}，正在替换..."
+    adb -s "${TARGET_IP}:5555" shell "rm -f ${OLD_APK}"
+fi
+
+adb -s "${TARGET_IP}:5555" push "${SERVICE_APK}" /system/priv-app/QsmwService/qsmw-service.apk
+adb -s "${TARGET_IP}:5555" shell "chmod 644 /system/priv-app/QsmwService/qsmw-service.apk"
+info "Service 部署完成"
+
+# ----------------------- 安装 Demo (user 0) -----------------------
+install_demo() {
+    local apk_path="$1"
+    local pkg_name="$2"
+    local demo_name="$3"
+
+    info "安装 ${demo_name}..."
+    
+    # 如果已安装，先卸载
+    if adb -s "${TARGET_IP}:5555" shell "pm list packages --user 0 ${pkg_name}" | grep -q "${pkg_name}"; then
+        warn "${pkg_name} 已存在于 user 0，先卸载旧版本..."
+        adb -s "${TARGET_IP}:5555" shell "pm uninstall --user 0 ${pkg_name}" >/dev/null 2>&1 || true
+    fi
+
+    adb -s "${TARGET_IP}:5555" install -r --user 0 "${apk_path}"
+    info "${demo_name} 安装完成"
+}
+
+install_demo "${DEMO_OTA}"     "com.qsjnic.demo.ota"     "OTA Demo"
+install_demo "${DEMO_RADIO}"   "com.qsjnic.demo.radio"   "Radio Demo"
+install_demo "${DEMO_VEHICLE}" "com.qsjnic.demo.vehicle" "Vehicle Demo"
+
+# ----------------------- 重启 -----------------------
+warn "即将重启车机以加载系统级 Service..."
+sleep 2
+adb -s "${TARGET_IP}:5555" reboot
+
+info "已发送重启指令，等待设备重新上线..."
+sleep 10
+adb -s "${TARGET_IP}:5555" wait-for-device
+info "车机已重新上线"
+
+# ----------------------- 验证阶段 -----------------------
+info "========== 开始验证 =========="
+
+# 1. 校验包版本
+info "[验证1/6] 检查 Service 包信息..."
+adb -s "${TARGET_IP}:5555" shell "dumpsys package com.qsjnic.mcu.service | grep -E 'versionCode|versionName'" || {
+    error "无法获取 Service 包信息，可能未正确安装"
+    exit 1
+}
+
+# 2. 校验预置路径
+info "[验证2/6] 检查预置路径..."
+adb -s "${TARGET_IP}:5555" shell "ls -lZ /system/priv-app/QsmwService/"
+
+# 3. 校验权限声明
+info "[验证3/6] 检查权限声明..."
+adb -s "${TARGET_IP}:5555" shell "dumpsys package com.qsjnic.mcu.service | grep -A2 'cn.qsjnic.permission.MCU_ACCESS'" || warn "未找到权限声明（可能不影响）"
+
+# 4. 检查 demo 是否在 user 0
+info "[验证4/6] 检查 Demo 应用安装状态（user 0）..."
+for pkg in com.qsjnic.demo.ota com.qsjnic.demo.radio com.qsjnic.demo.vehicle; do
+    if adb -s "${TARGET_IP}:5555" shell "pm list packages --user 0 ${pkg}" | grep -q "${pkg}"; then
+        info "  ✓ ${pkg}"
+    else
+        error "  ✗ ${pkg} 未安装"
+    fi
+done
+
+# 5. 检查服务是否运行
+info "[验证5/6] 检查服务进程..."
+adb -s "${TARGET_IP}:5555" shell "ps -A | grep -E 'qsjnic|mcu' || echo '(暂无进程，可能尚未自启)'"
+
+# 6. 抓取相关日志
+info "[验证6/6] 抓取最近 30 秒 Mcu 相关日志..."
+adb -s "${TARGET_IP}:5555" logcat -d -t 30s --pid=$(adb -s "${TARGET_IP}:5555" shell "pidof com.qsjnic.mcu.service 2>/dev/null || echo ''") 2>/dev/null | tail -20 || {
+    adb -s "${TARGET_IP}:5555" logcat -d -t 30s | grep -iE 'QsMcuSystemService|McuLink|qsjnic' | tail -20 || true
+}
+
+info "========== 部署与验证完成 =========="
+echo ""
+echo "手动测试建议:"
+echo "  1. 打开 Vehicle Demo 验证车辆信息/Key 域方控接口"
+echo "  2. 打开 Radio Demo 验证收音机接口"
+echo "  3. 打开 OTA Demo 验证 OTA 升级接口"
+echo "  4. 实时观察日志: adb -s ${TARGET_IP}:5555 logcat | grep -iE 'QsMcuSystemService|McuLink|qsjnic'"
+echo ""
+echo "注意: 当前 APK 使用 sim.jks (debug) 签名，非 platform 签名。"
+echo "      如遇到 SELinux 拒绝或 system_app 权限不足，请联系系统工程师确认:"
+echo "      - /dev/ttyS7 的权限和 SELinux 标签"
+echo "      - 是否需要使用 platform 签名重新签名"
diff --git a/scripts/generate_release_readme.py b/scripts/generate_release_readme.py
new file mode 100644
index 0000000..8088f2c
--- /dev/null
+++ b/scripts/generate_release_readme.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+"""Generate release/README.md from actual artifacts in a release directory.
+
+Usage:
+    python3 scripts/generate_release_readme.py <release_dir> [version_name] [version_code]
+"""
+
+import hashlib
+import os
+import sys
+import datetime
+
+FILES = [
+    ("qsmw-service.apk", "MCU SystemService APK，用于系统集成"),
+    ("qsmw-sdk.aar", "业务应用侧 SDK 二进制包"),
+    ("qsmw-contract.aar", "AIDL / Parcelable 契约二进制包"),
+    ("qsmw-demos.zip", "Android Studio 示例工程源码包，包含 vehicle、radio、ota 三个接入示例"),
+    ("qsmw-ota-demo.apk", "OTA 示例应用 APK，基于 qsmw-demos.zip 中的 ota-demo 工程构建"),
+    ("qsmw-radio-demo.apk", "Radio 示例应用 APK，基于 qsmw-demos.zip 中的 radio-demo 工程构建"),
+    ("qsmw-vehicle-demo.apk", "Vehicle/Key 示例应用 APK，基于 qsmw-demos.zip 中的 vehicle-demo 工程构建"),
+]
+
+STATIC_DOC_DESCRIPTION = "APP 开发对接文档（Markdown 版本）"
+
+
+def sha256_file(path: str) -> str:
+    h = hashlib.sha256()
+    with open(path, "rb") as f:
+        while True:
+            chunk = f.read(8192)
+            if not chunk:
+                break
+            h.update(chunk)
+    return h.hexdigest()
+
+
+def main() -> int:
+    if len(sys.argv) < 2:
+        print(f"Usage: {sys.argv[0]} <release_dir> [version_name] [version_code]", file=sys.stderr)
+        return 1
+
+    release_dir = sys.argv[1]
+    version_name = sys.argv[2] if len(sys.argv) > 2 else "1.0.0-beta7"
+    version_code = sys.argv[3] if len(sys.argv) > 3 else "107"
+
+    doc_name = f"QSJNIC_MCU_SDK_开发对接文档_v{version_name}.md"
+
+    static_files = [
+        (doc_name, STATIC_DOC_DESCRIPTION),
+        ("API_REFERENCE.md", "SDK 接口与回调说明"),
+        ("INTEGRATION_GUIDE.md", "系统集成步骤与验证命令"),
+        ("INTERFACE_CONSISTENCY_REPORT.md", "协议/代码/文档一致性修订报告"),
+        ("RELEASE_INFO.txt", "构建元数据与 SHA-256"),
+        ("SHA256SUMS.txt", "校验文件"),
+        ("VERSION.txt", "版本文件"),
+        ("changes.patch", "自上一版本以来的完整代码变更 diff"),
+    ]
+
+    key_sha_files = [
+        "qsmw-service.apk",
+        "qsmw-sdk.aar",
+        "qsmw-contract.aar",
+        "qsmw-demos.zip",
+        "qsmw-ota-demo.apk",
+        "qsmw-radio-demo.apk",
+        "qsmw-vehicle-demo.apk",
+        doc_name,
+        "API_REFERENCE.md",
+        "INTERFACE_CONSISTENCY_REPORT.md",
+        "changes.patch",
+    ]
+
+    # AGP / SDK versions are read from the project root.
+    project_root = os.path.join(os.path.dirname(__file__), "..")
+    agp = "8.8.2"
+    compile_sdk = "34"
+    min_sdk = "26"
+    try:
+        toml = os.path.join(project_root, "gradle", "libs.versions.toml")
+        with open(toml, "r", encoding="utf-8") as f:
+            for line in f:
+                line = line.strip()
+                if line.startswith("agp") and "=" in line:
+                    agp = line.split("=")[-1].strip().strip('"')
+                elif line.startswith("compileSdk") and "=" in line:
+                    compile_sdk = line.split("=")[-1].strip().strip('"')
+                elif line.startswith("minSdk") and "=" in line:
+                    min_sdk = line.split("=")[-1].strip().strip('"')
+    except Exception:
+        pass
+
+    file_entries = []
+    latest_mtime = 0.0
+    for name, desc in FILES + static_files:
+        path = os.path.join(release_dir, name)
+        if os.path.exists(path):
+            size = os.path.getsize(path)
+            mtime = os.path.getmtime(path)
+            if mtime > latest_mtime:
+                latest_mtime = mtime
+            sha = sha256_file(path)
+            file_entries.append((name, size, desc, sha))
+
+    tz = datetime.timezone(datetime.timedelta(hours=8))
+    build_time = datetime.datetime.fromtimestamp(latest_mtime, tz=tz).isoformat()
+
+    sha_map = {name: sha for name, _, _, sha in file_entries}
+
+    lines = [
+        "# QSJNIC MCU SystemService 交付说明",
+        "",
+        f"本目录为 `{version_name}` 对外交付产物集合。交付内容包含运行时二进制包、SDK 二进制包、接口契约二进制包、示例工程源码包及必要的集成说明文档。除 `qsmw-demos.zip` 中的示例工程源码外，不包含 SystemService、SDK、Contract 的实现源码或内部发版流程文档。",
+        "",
+        "## 版本信息",
+        "",
+        f"- 版本号：`{version_name}`",
+        f"- Service `versionCode`：`{version_code}`",
+        f"- Service `versionName`：`{version_name}`",
+        f"- 构建时间：`{build_time}`",
+        f"- AGP：`{agp}`",
+        f"- compileSdk：`{compile_sdk}`",
+        f"- minSdk：`{min_sdk}`",
+        "",
+        "## 交付清单",
+        "",
+        "| 文件 | 大小 | 说明 |",
+        "|---|---:|---|",
+    ]
+    for name, size, desc, _ in file_entries:
+        lines.append(f"| `{name}` | {size:,} bytes | {desc} |")
+
+    lines += [
+        "",
+        "> 注：`qsmw-demos.zip` 中同时包含示例工程源码，各示例工程已内置本版本 `qsmw-sdk.aar` 与 `qsmw-contract.aar`。",
+        "",
+        "`qsmw-demos.zip` 为交付用示例源码包，内部包含可直接导入 Android Studio 的示例工程。除该示例源码包外，交付目录不包含其他 Java/Kotlin/C/C++ 源码、Gradle 工程文件或内部版本管理文档。",
+        "",
+        "## 校验信息",
+        "",
+        "关键产物 SHA-256：",
+        "",
+        "```text",
+    ]
+    present_key_files = [name for name in key_sha_files if name in sha_map]
+    for name in present_key_files:
+        lines.append(f"{name:<48} {sha_map[name]}")
+    checksum_cmd = "shasum -a 256 " + " ".join(present_key_files)
+    lines += [
+        "```",
+        "",
+        "校验命令：",
+        "",
+        "```bash",
+        checksum_cmd,
+        "```",
+        "",
+        "## 签名说明",
+        "",
+        "本版本仓库构建统一使用 `docs/keystore/sim.jks` 签名，保证 `qsmw-service.apk` 与相关验证包使用一致的签名体系。",
+        "",
+        "证书 SHA-256：",
+        "",
+        "```text",
+        "c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8",
+        "```",
+        "",
+        "如最终系统镜像要求 platform 签名，应在甲方系统构建流程中对 `qsmw-service.apk` 重新签名后再预置。",
+        "",
+        "## 构建质量",
+        "",
+        "本版本在打包前已通过自动化单元测试门禁，覆盖 `service` 模块核心 Java 逻辑：",
+        "",
+        "- McuBinder 参数边界与链路状态测试",
+        "- UpgradeManager 状态机与参数校验测试",
+        "- KeyInjector 按键映射与事件注入测试",
+        "- Feature 状态同步与并发安全测试",
+        "- BootCompletedReceiver 生命周期测试",
+        "",
+        "测试命令：",
+        "",
+        "```bash",
+        "./gradlew :service:testDebugUnitTest",
+        "```",
+        "",
+        "`assembleRelease` 已自动依赖上述测试任务，每次 release 构建前会自动执行。",
+        "",
+        "## 系统集成",
+        "",
+        "1. 将 `qsmw-service.apk` 预置到 `/system/priv-app/QsmwService/QsmwService.apk`",
+        "2. 确认 `/dev/ttyS7` 的 Linux 权限、SELinux 策略和服务启动策略已配置完成",
+        "3. 如需 platform 签名，先完成重签，再纳入系统镜像",
+        "4. 参考 `INTEGRATION_GUIDE.md` 完成安装、启动和权限验证",
+        "5. 参考 `API_REFERENCE.md` 完成业务接口对接",
+        "",
+        "> **beta6 变更提示**：本版本在 `service/AndroidManifest.xml` 中为 `QsMcuSystemService` 和 `BootCompletedReceiver` 增加了 `android:singleUser=\"true\"`，确保服务仅在 user 0 中运行，防止多用户场景下串口竞争。",
+        ">",
+        "> **beta7 变更提示**：Key 域键值定义从 MC3 标准切换为帕杰罗映射表（Key.xlsx \"其它项目\"），新增 5 个扩展键值（0x0E/0x0F/0x18/0x19/0x25），校验范围放宽至 0x00-0x25。配套 MCU 固件更新至 PAJERO69XZA0120018 (SW: 0302_20260527)。",
+        "",
+        "## 业务应用接入",
+        "",
+        "1. 将 `qsmw-sdk.aar` 与 `qsmw-contract.aar` 放入业务工程 `libs/`",
+        "2. 在业务工程中配置 `implementation(fileTree(\"libs\"))`",
+        "3. 在业务应用 `AndroidManifest.xml` 中声明：",
+        "",
+        "```xml",
+        '<uses-permission android:name="cn.qsjnic.permission.MCU_ACCESS" />',
+        "```",
+        "",
+        "4. 业务应用需使用与 SystemService 兼容的签名策略，否则受 `signature|privileged` 权限限制，可能无法绑定服务",
+        "",
+        "## 示例工程",
+        "",
+        "`qsmw-demos.zip` 包含以下 Android Studio 示例工程源码：",
+        "",
+        "1. `vehicle-demo`：车辆信息与 Key 域方控接口示例",
+        "2. `radio-demo`：收音机接口示例",
+        "3. `ota-demo`：OTA 接口示例",
+        "",
+        "各示例工程已内置本版本交付的 `qsmw-sdk.aar` 与 `qsmw-contract.aar`，用于验证 SDK 接入方式和基础调用流程。",
+        "",
+        "`qsmw-ota-demo.apk`、`qsmw-radio-demo.apk`、`qsmw-vehicle-demo.apk` 分别为三个示例工程的 release 构建产物，包名分别为 `com.qsjnic.demo.ota`、`com.qsjnic.demo.radio`、`com.qsjnic.demo.vehicle`，版本均为 `0.1.0`，使用 `docs/keystore/sim.jks` 签名。",
+        "",
+        "## 验收建议",
+        "",
+        "系统集成完成后，建议按以下顺序验证：",
+        "",
+        f"1. 校验 APK 版本：`versionCode={version_code}`，`versionName={version_name}`",
+        "2. 校验预置路径：`/system/priv-app/QsmwService/QsmwService.apk`",
+        "3. 校验权限声明：`cn.qsjnic.permission.MCU_ACCESS`",
+        "4. 校验串口权限：`/dev/ttyS7`",
+        "5. 启动服务并检查 `QsMcuSystemService`、`McuLink` 相关日志",
+        "",
+        "具体命令见 `INTEGRATION_GUIDE.md`。",
+        "",
+    ]
+
+    out_path = os.path.join(release_dir, "README.md")
+    with open(out_path, "w", encoding="utf-8") as f:
+        f.write("\n".join(lines))
+
+    print(f"Generated {out_path}")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/serial_test.py b/scripts/serial_test.py
new file mode 100644
index 0000000..aa872f4
--- /dev/null
+++ b/scripts/serial_test.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""串口测试脚本 - 连接MCU调试串口并执行KeyTest命令"""
+
+import sys
+import time
+
+try:
+    import serial
+except ImportError:
+    print("缺少 pyserial，请先安装: pip3 install pyserial")
+    sys.exit(1)
+
+SERIAL_PORT = "/dev/cu.usbmodem21302"
+BAUDRATE = 921600
+
+def main():
+    print("=" * 60)
+    print(f"串口测试工具")
+    print(f"端口: {SERIAL_PORT}")
+    print(f"波特率: {BAUDRATE}")
+    print("=" * 60)
+
+    try:
+        ser = serial.Serial(
+            port=SERIAL_PORT,
+            baudrate=BAUDRATE,
+            bytesize=serial.EIGHTBITS,
+            parity=serial.PARITY_NONE,
+            stopbits=serial.STOPBITS_ONE,
+            timeout=0.1,
+            write_timeout=1.0
+        )
+    except serial.SerialException as e:
+        print(f"\n❌ 无法打开串口: {e}")
+        sys.exit(1)
+
+    print(f"\n✅ 串口已打开: {SERIAL_PORT}")
+    ser.flushInput()
+    ser.flushOutput()
+
+    # 阶段1: 先监听几秒，看看MCU是否有主动输出
+    print("\n📥 阶段1: 监听MCU是否有主动输出 (3秒)...")
+    print("-" * 50)
+    data1 = bytearray()
+    start = time.time()
+    while time.time() - start < 3.0:
+        chunk = ser.read(1024)
+        if chunk:
+            data1.extend(chunk)
+            for b in chunk:
+                print(f"0x{b:02X} ", end="", flush=True)
+        else:
+            time.sleep(0.05)
+    print("\n" + "-" * 50)
+    if data1:
+        print(f"✅ 收到 {len(data1)} 字节数据")
+        # 尝试解码为ASCII看看
+        try:
+            ascii_text = data1.decode('ascii', errors='replace')
+            if any(c.isprintable() for c in ascii_text):
+                print(f"ASCII内容: {ascii_text.strip()}")
+        except:
+            pass
+    else:
+        print("⚠️  未收到任何数据")
+        print("   可能原因: MCU未上电 / 接线错误 / 波特率不对 / 串口被占用")
+
+    # 阶段2: 发送 KeyTest 按下命令
+    print("\n📤 阶段2: 发送 KeyTest 命令 (下一曲 按下)...")
+    ser.flushInput()
+    cmd = b"KeyTest 1 0x0F 0x01\r\n"
+    ser.write(cmd)
+    print(f"发送: {cmd.strip().decode()}")
+    time.sleep(0.3)
+
+    print("-" * 50)
+    data2 = bytearray()
+    start = time.time()
+    while time.time() - start < 2.0:
+        chunk = ser.read(1024)
+        if chunk:
+            data2.extend(chunk)
+            for b in chunk:
+                print(f"0x{b:02X} ", end="", flush=True)
+        else:
+            time.sleep(0.05)
+    print("\n" + "-" * 50)
+    if data2:
+        print(f"✅ 收到 {len(data2)} 字节响应")
+        try:
+            ascii_text = data2.decode('ascii', errors='replace')
+            if any(c.isprintable() for c in ascii_text):
+                print(f"ASCII内容: {ascii_text.strip()}")
+        except:
+            pass
+    else:
+        print("⚠️  未收到响应")
+
+    # 阶段3: 发送 KeyTest 弹起命令
+    print("\n📤 阶段3: 发送 KeyTest 命令 (下一曲 弹起)...")
+    ser.flushInput()
+    cmd = b"KeyTest 1 0x0F 0x00\r\n"
+    ser.write(cmd)
+    print(f"发送: {cmd.strip().decode()}")
+    time.sleep(0.3)
+
+    print("-" * 50)
+    data3 = bytearray()
+    start = time.time()
+    while time.time() - start < 2.0:
+        chunk = ser.read(1024)
+        if chunk:
+            data3.extend(chunk)
+            for b in chunk:
+                print(f"0x{b:02X} ", end="", flush=True)
+        else:
+            time.sleep(0.05)
+    print("\n" + "-" * 50)
+    if data3:
+        print(f"✅ 收到 {len(data3)} 字节响应")
+        try:
+            ascii_text = data3.decode('ascii', errors='replace')
+            if any(c.isprintable() for c in ascii_text):
+                print(f"ASCII内容: {ascii_text.strip()}")
+        except:
+            pass
+    else:
+        print("⚠️  未收到响应")
+
+    ser.close()
+    print("\n" + "=" * 60)
+    print("串口已关闭")
+    print("=" * 60)
+
+if __name__ == "__main__":
+    main()
diff --git a/sdk/src/main/java/com/qsjnic/mcu/sdk/KeyListener.java b/sdk/src/main/java/com/qsjnic/mcu/sdk/KeyListener.java
new file mode 100644
index 0000000..70f161b
--- /dev/null
+++ b/sdk/src/main/java/com/qsjnic/mcu/sdk/KeyListener.java
@@ -0,0 +1,6 @@
+package com.qsjnic.mcu.sdk;
+
+/** Listener for the independent Key domain (msgType 0x02). */
+public interface KeyListener {
+    default void onKeyEvent(int keyValue, int keyStatus) {}
+}
diff --git a/sdk/src/main/java/com/qsjnic/mcu/sdk/McuClient.java b/sdk/src/main/java/com/qsjnic/mcu/sdk/McuClient.java
index e422080..06cdacd 100644
--- a/sdk/src/main/java/com/qsjnic/mcu/sdk/McuClient.java
+++ b/sdk/src/main/java/com/qsjnic/mcu/sdk/McuClient.java
@@ -50,11 +50,19 @@ public final class McuClient {
     private final CopyOnWriteArrayList<ReadyListener> readyListeners = new CopyOnWriteArrayList<>();
 
     private final McuVehicleClient  vehicle  = new McuVehicleClient(this);
+    private final McuKeyClient      key      = new McuKeyClient(this);
     private final McuRadioClient    radio    = new McuRadioClient(this);
     private final McuUpgradeClient  upgrade  = new McuUpgradeClient(this);
 
     private Context     appContext;
     private IMcuService service;
+    private int bindPackageIndex = 0;
+
+    private static final String[] SERVICE_PACKAGES = {
+            McuServiceManager.SERVICE_PACKAGE,
+            McuServiceManager.SERVICE_PACKAGE + ".dev",
+            McuServiceManager.SERVICE_PACKAGE + ".test"
+    };
 
     /** Re-register unified frame listener after reconnect. */
     private volatile boolean frameListenerRegistered = false;
@@ -147,12 +155,14 @@ public final class McuClient {
         if (ctx == null) return;
 
         Intent i = new Intent(McuServiceManager.SERVICE_INTENT_ACTION);
-        i.setPackage(McuServiceManager.SERVICE_PACKAGE);
+        String pkg = SERVICE_PACKAGES[bindPackageIndex % SERVICE_PACKAGES.length];
+        i.setPackage(pkg);
         boolean ok = ctx.bindService(i, conn, Context.BIND_AUTO_CREATE);
         Log.i(TAG, "bindService(" + McuServiceManager.SERVICE_INTENT_ACTION
-                + " pkg=" + McuServiceManager.SERVICE_PACKAGE + ") -> " + ok);
+                + " pkg=" + pkg + ") -> " + ok);
         if (!ok) {
             // Service may not yet be installed/refreshed; retry shortly.
+            bindPackageIndex++;
             mainHandler.postDelayed(this::bind, 2000);
         }
     }
@@ -164,6 +174,9 @@ public final class McuClient {
     /** Vehicle / Public domain Facade. */
     public McuVehicleClient vehicle() { return vehicle; }
 
+    /** Key domain Facade. */
+    public McuKeyClient key() { return key; }
+
     /** Radio domain Facade. */
     public McuRadioClient radio() { return radio; }
 
diff --git a/sdk/src/main/java/com/qsjnic/mcu/sdk/McuKeyClient.java b/sdk/src/main/java/com/qsjnic/mcu/sdk/McuKeyClient.java
new file mode 100644
index 0000000..b9e7b74
--- /dev/null
+++ b/sdk/src/main/java/com/qsjnic/mcu/sdk/McuKeyClient.java
@@ -0,0 +1,116 @@
+package com.qsjnic.mcu.sdk;
+
+import android.os.RemoteException;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.qsjnic.mcu.contract.IMcuKeyListener;
+import com.qsjnic.mcu.contract.IMcuService;
+import com.qsjnic.mcu.contract.McuErrorCode;
+import com.qsjnic.mcu.domain.Cmds;
+import com.qsjnic.mcu.domain.Commands;
+import com.qsjnic.mcu.domain.MsgType;
+
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/** Key domain facade (msgType 0x02). */
+public final class McuKeyClient {
+
+    private static final String TAG = "McuKeyClient";
+
+    // Pajero SWC key values (Key.xlsx "其它项目" mapping)
+    public static final int KEY_VALUE_NONE            = 0x00;
+    public static final int KEY_VALUE_POWER           = 0x01;
+    public static final int KEY_VALUE_HOME            = 0x02;
+    public static final int KEY_VALUE_VOICE           = 0x03;
+    public static final int KEY_VALUE_MUSIC           = 0x04;
+    public static final int KEY_VALUE_RADIO           = 0x05;
+    public static final int KEY_VALUE_TEL             = 0x06;
+    public static final int KEY_VALUE_TFT             = 0x07;
+    public static final int KEY_VALUE_VOL_DEC         = 0x08;
+    public static final int KEY_VALUE_VOL_INC         = 0x09;
+    public static final int KEY_VALUE_MUTE            = 0x0C;
+    public static final int KEY_VALUE_MODE            = 0x0D;
+    public static final int KEY_VALUE_PREV            = 0x0E;
+    public static final int KEY_VALUE_NEXT            = 0x0F;
+    public static final int KEY_VALUE_ANSWER          = 0x18;
+    public static final int KEY_VALUE_REJECT          = 0x19;
+    public static final int KEY_VALUE_CAMERA          = 0x25;
+
+    public static final int KEY_STATUS_RELEASE       = 0x00;
+    public static final int KEY_STATUS_SHORT_PRESS   = 0x01;
+    public static final int KEY_STATUS_STUCK         = 0x02;
+    public static final int KEY_STATUS_ENCODER_LEFT  = 0x03;
+    public static final int KEY_STATUS_ENCODER_RIGHT = 0x04;
+
+    private final McuClient parent;
+    private final CopyOnWriteArrayList<KeyListener> javaListeners = new CopyOnWriteArrayList<>();
+
+    private final IMcuKeyListener.Stub aidlListener = new IMcuKeyListener.Stub() {
+        @Override public void onKeyEvent(int keyValue, int keyStatus) {
+            for (KeyListener l : javaListeners) safe(() -> l.onKeyEvent(keyValue, keyStatus));
+        }
+    };
+
+    private boolean registeredWithService = false;
+
+    McuKeyClient(@NonNull McuClient parent) {
+        this.parent = parent;
+        parent.addReadyListener(this::onServiceReady);
+    }
+
+    private void onServiceReady() {
+        registeredWithService = false;
+        if (!javaListeners.isEmpty()) ensureRegistered();
+    }
+
+    private void ensureRegistered() {
+        if (registeredWithService) return;
+        IMcuService svc = parent.getServiceUnchecked();
+        if (svc == null) return;
+        try {
+            svc.registerKeyListener(aidlListener);
+            registeredWithService = true;
+            Log.i(TAG, "registered key listener with Service");
+        } catch (RemoteException e) {
+            Log.e(TAG, "registerKeyListener failed", e);
+        }
+    }
+
+    public void addListener(@NonNull KeyListener l) {
+        javaListeners.add(l);
+        ensureRegistered();
+    }
+
+    public void removeListener(@Nullable KeyListener l) {
+        if (l != null) javaListeners.remove(l);
+    }
+
+    public int sendKeyEvent(int keyValue, int keyStatus) {
+        IMcuService svc = parent.getServiceUnchecked();
+        if (svc == null) return McuErrorCode.ERR_SERVICE;
+        try {
+            return svc.sendKeyEvent(keyValue, keyStatus);
+        } catch (RemoteException e) {
+            return McuErrorCode.ERR_LINK_DOWN;
+        }
+    }
+
+    public int postKeyEvent(int keyValue, int keyStatus) {
+        try {
+            if (parent.getServiceUnchecked() == null) return McuErrorCode.ERR_SERVICE;
+            parent.postCommand(MsgType.KEY, Cmds.Key.A2M_KEY, Commands.Key.key(keyValue, keyStatus));
+            return McuErrorCode.OK;
+        } catch (IllegalArgumentException e) {
+            return McuErrorCode.ERR_INVALID_ARG;
+        }
+    }
+
+    private interface SafeCall { void run(); }
+
+    private static void safe(SafeCall call) {
+        try { call.run(); } catch (Throwable t) { Log.w(TAG, "KeyListener threw", t); }
+    }
+}
diff --git a/sdk/src/main/java/com/qsjnic/mcu/sdk/McuRadioClient.java b/sdk/src/main/java/com/qsjnic/mcu/sdk/McuRadioClient.java
index 9b3bb3c..51b9e06 100644
--- a/sdk/src/main/java/com/qsjnic/mcu/sdk/McuRadioClient.java
+++ b/sdk/src/main/java/com/qsjnic/mcu/sdk/McuRadioClient.java
@@ -103,9 +103,13 @@ public final class McuRadioClient {
     }
 
     public int setBand(int band) {
+        return setBand(band, 0);
+    }
+
+    public int setBand(int band, int freq) {
         IMcuService s = parent.getServiceUnchecked();
         if (s == null) return McuErrorCode.ERR_SERVICE;
-        try { return s.setBand(band); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
+        try { return s.setBandWithFreq(band, freq); } catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
     }
 
     public int setFrequency(int freq) {
diff --git a/sdk/src/main/java/com/qsjnic/mcu/sdk/McuUpgradeClient.java b/sdk/src/main/java/com/qsjnic/mcu/sdk/McuUpgradeClient.java
index 53b2aba..d53e078 100644
--- a/sdk/src/main/java/com/qsjnic/mcu/sdk/McuUpgradeClient.java
+++ b/sdk/src/main/java/com/qsjnic/mcu/sdk/McuUpgradeClient.java
@@ -127,9 +127,13 @@ public final class McuUpgradeClient {
     }
 
     public int sendBootUpgradeChecksum(int checksum) {
+        return McuErrorCode.ERR_INVALID_ARG;
+    }
+
+    public int sendBootUpgradeChecksumMd5(byte[] md5) {
         IMcuService svc = parent.getServiceUnchecked();
         if (svc == null) return -4;
-        try { return svc.sendBootUpgradeChecksum(checksum); }
+        try { return svc.sendBootUpgradeChecksumMd5(md5); }
         catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
     }
 
diff --git a/sdk/src/main/java/com/qsjnic/mcu/sdk/McuVehicleClient.java b/sdk/src/main/java/com/qsjnic/mcu/sdk/McuVehicleClient.java
index 33e46c8..d534aa2 100644
--- a/sdk/src/main/java/com/qsjnic/mcu/sdk/McuVehicleClient.java
+++ b/sdk/src/main/java/com/qsjnic/mcu/sdk/McuVehicleClient.java
@@ -283,9 +283,13 @@ public final class McuVehicleClient {
     }
 
     public int requestTripClear() {
+        return requestTripClear(1);
+    }
+
+    public int requestTripClear(int type) {
         IMcuService svc = parent.getServiceUnchecked();
         if (svc == null) return -4;
-        try { return svc.requestTripClear(); }
+        try { return svc.requestTripClearType(type); }
         catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
     }
 
@@ -310,6 +314,13 @@ public final class McuVehicleClient {
         catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
     }
 
+    public int setGpsInfoFull(int moduleStatus, int lonPpm, int lonStatus, int latPpm, int latStatus) {
+        IMcuService svc = parent.getServiceUnchecked();
+        if (svc == null) return -4;
+        try { return svc.setGpsInfoFull(moduleStatus, lonPpm, lonStatus, latPpm, latStatus); }
+        catch (RemoteException e) { return McuErrorCode.ERR_LINK_DOWN; }
+    }
+
     public int setTotalMiles(int milesTenthKm) {
         IMcuService svc = parent.getServiceUnchecked();
         if (svc == null) return -4;
diff --git a/service/PACKAGING.md b/service/PACKAGING.md
new file mode 100644
index 0000000..c9f63de
--- /dev/null
+++ b/service/PACKAGING.md
@@ -0,0 +1,203 @@
+# QsmwService 打包说明
+
+适用模块: `service`
+
+服务包名: `com.qsjnic.mcu.service`
+
+产物类型: Android APK
+
+当前版本: `versionCode=107`, `versionName=1.0.0-beta7`
+
+## 1. 前置条件
+
+在项目根目录执行打包命令:
+
+```bash
+cd /Users/dapeng/Projects/yiren/qsjnic
+```
+
+确认 Android SDK、NDK、CMake 和 Gradle wrapper 可用。
+
+`service` 模块依赖:
+
+- `:contract`
+- Android Gradle Plugin
+- CMake native build: `service/src/main/cpp/CMakeLists.txt`
+
+## 2. Debug 包
+
+用于本地开发和临时调试。
+
+打包命令:
+
+```bash
+./gradlew :service:assembleDebug
+```
+
+产物路径:
+
+```text
+service/build/outputs/apk/debug/service-debug.apk
+```
+
+Debug 包特点:
+
+- applicationId 带后缀 `.dev`
+- 实际包名为 `com.qsjnic.mcu.service.dev`
+- 使用 `docs/keystore/sim.jks` 签名
+- debug manifest 会移除 `cn.qsjnic.permission.MCU_ACCESS` 权限声明，服务本地调试时不强制该权限
+
+Debug 包不能作为系统集成交付包。
+
+## 3. Release 包
+
+用于生成正式 APK。
+
+打包命令:
+
+```bash
+./gradlew :service:assembleRelease
+```
+
+产物路径:
+
+```text
+service/build/outputs/apk/release/service-release.apk
+```
+
+Release 包特点:
+
+- applicationId 为 `com.qsjnic.mcu.service`
+- 声明 `cn.qsjnic.permission.MCU_ACCESS`
+- `MCU_ACCESS` 权限等级为 `signature|privileged`
+- 声明 `RECEIVE_BOOT_COMPLETED`
+- 支持前台服务类型 `dataSync`
+- 默认 ABI 为 `arm64-v8a`
+
+## 4. Release 签名配置
+
+Release 签名直接使用:
+
+```text
+docs/keystore/sim.jks
+```
+
+当前配置:
+
+```properties
+storePassword=android
+keyAlias=android_key
+keyPassword=android
+```
+
+正式打包不再依赖 `keystore/release.properties`。如甲方系统集成要求 platform 签名，仍需在系统构建流程中重签 APK。
+
+当前 `docs/keystore/sim.jks` 签名信息:
+
+```text
+certificate DN: EMAILADDRESS=android@android.com, CN=Android, OU=Android, O=Android, L=Mountain View, ST=California, C=US
+certificate SHA-256:
+c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8
+```
+
+注意:
+
+- `docs/keystore/sim.jks` 不是目标系统 platform 签名。
+- 如果系统集成要求 platform 签名，应由系统构建流程使用 `certificate: "platform"` 重签 APK。
+- 如果要求使用 `android.uid.system`，还需要使用带 `android:sharedUserId="android.uid.system"` 的 Manifest 版本重新构建 APK。
+
+## 5. 检查 APK 信息
+
+查看包名和版本:
+
+```bash
+aapt dump badging service/build/outputs/apk/release/service-release.apk | head -n 5
+```
+
+预期:
+
+```text
+package: name='com.qsjnic.mcu.service' versionCode='107' versionName='1.0.0-beta7'
+```
+
+查看签名:
+
+```bash
+apksigner verify --print-certs service/build/outputs/apk/release/service-release.apk
+```
+
+预期应看到 `docs/keystore/sim.jks` 证书，或系统集成后看到目标系统 platform 证书。
+
+查看 Manifest:
+
+```bash
+aapt dump xmltree service/build/outputs/apk/release/service-release.apk AndroidManifest.xml
+```
+
+重点确认:
+
+- package: `com.qsjnic.mcu.service`
+- service: `.QsMcuSystemService`
+- receiver: `.BootCompletedReceiver`
+- permission: `cn.qsjnic.permission.MCU_ACCESS`
+- uses-permission: `android.permission.RECEIVE_BOOT_COMPLETED`
+- uses-permission: `android.permission.FOREGROUND_SERVICE`
+- uses-permission: `android.permission.FOREGROUND_SERVICE_DATA_SYNC`
+
+## 6. 更新 release 目录
+
+如果需要将新构建的 release APK 更新到 beta6 交付目录:
+
+```bash
+cp service/build/outputs/apk/release/service-release.apk \
+   release/v1.0.0-beta6/qsmw-service.apk
+```
+
+重新生成 SHA256:
+
+```bash
+cd release/v1.0.0-beta6
+shasum -a 256 qsmw-service.apk qsmw-sdk.aar qsmw-contract.aar qsmw-demos.zip qsmw-ota-demo.apk qsmw-radio-demo.apk qsmw-vehicle-demo.apk QSJNIC_MCU_SDK_开发对接文档_v1.0.0-beta7.md > SHA256SUMS.txt
+```
+
+## 7. 安装与验证
+
+普通安装仅用于开发验证:
+
+```bash
+adb install -r service/build/outputs/apk/debug/service-debug.apk
+```
+
+系统服务能力必须通过系统预置验证:
+
+```text
+/system/priv-app/QsmwService/QsmwService.apk
+```
+
+设备侧验证:
+
+```bash
+adb shell dumpsys package com.qsjnic.mcu.service | grep -E "codePath|versionCode|versionName|flags|privateFlags"
+adb shell dumpsys package com.qsjnic.mcu.service | grep -E "RECEIVE_BOOT_COMPLETED|FOREGROUND_SERVICE|FOREGROUND_SERVICE_DATA_SYNC|MCU_ACCESS"
+adb shell am start-foreground-service -n com.qsjnic.mcu.service/.QsMcuSystemService
+adb shell logcat -d -s QsMcuBootReceiver QsMcuSystemService McuLink McuJni AndroidRuntime ActivityManager
+```
+
+预期:
+
+```text
+codePath=/system/priv-app/QsmwService
+versionCode=106
+versionName=1.0.0-beta7
+QsMcuSystemService: onCreate
+```
+
+## 8. 常见问题
+
+| 现象 | 原因 | 处理 |
+|---|---|---|
+| release 包签名不符合预期 | 构建脚本未使用 `docs/keystore/sim.jks` | 检查 Gradle signingConfig 后重新打包 |
+| APK 不是 platform 签名 | 只使用了仓库签名 keystore | 在系统构建中配置 `certificate: "platform"` |
+| 设备上 `codePath=/data/app/...` | 系统包被安装更新覆盖 | 清理 `/data/app` 覆盖包，只保留 `/system/priv-app` |
+| 服务启动 attach 失败 | SELinux 缺少 `activity_service` find 权限 | 增加 `allow qsmw_service activity_service:service_manager find;` |
+| `open(/dev/ttyS7) failed: Permission denied` | 串口 Linux 权限或 SELinux 不通 | 同时检查 `/dev/ttyS7` mode/group/ACL 和 `serial_device` allow |
diff --git a/service/build.gradle.kts b/service/build.gradle.kts
index c44ed7e..0d74151 100644
--- a/service/build.gradle.kts
+++ b/service/build.gradle.kts
@@ -1,5 +1,3 @@
-import java.util.Properties
-
 plugins {
     alias(libs.plugins.android.application)
 }
@@ -8,17 +6,12 @@ android {
     namespace = "com.qsjnic.mcu.service"
     compileSdk = libs.versions.compileSdk.get().toInt()
 
-    val releaseProps = Properties().apply {
-        rootProject.file("keystore/release.properties").takeIf { it.exists() }
-            ?.inputStream()?.use { load(it) }
-    }
-
     defaultConfig {
         applicationId = "com.qsjnic.mcu.service"
         minSdk = libs.versions.minSdk.get().toInt()
         targetSdk = libs.versions.targetSdk.get().toInt()
-        versionCode = 104
-        versionName = "1.0.0-beta4"
+        versionCode = 107
+        versionName = "1.0.0-beta7"
 
         ndk {
             // Target only the device's actual ABI to keep APK small and
@@ -42,18 +35,10 @@ android {
 
     signingConfigs {
         create("sharedDebug") {
-            storeFile = file("../keystore/qsjnic_debug.jks")
-            storePassword = "qsjnic123"
-            keyAlias = "qsjnic_debug"
-            keyPassword = "qsjnic123"
-        }
-        if (releaseProps.isNotEmpty()) {
-            create("releaseSign") {
-                storeFile = rootProject.file(releaseProps["RELEASE_STORE_FILE"] as String)
-                storePassword = releaseProps["RELEASE_STORE_PASSWORD"] as String
-                keyAlias = releaseProps["RELEASE_KEY_ALIAS"] as String
-                keyPassword = releaseProps["RELEASE_KEY_PASSWORD"] as String
-            }
+            storeFile = rootProject.file("docs/keystore/sim.jks")
+            storePassword = "android"
+            keyAlias = "android_key"
+            keyPassword = "android"
         }
     }
 
@@ -65,8 +50,7 @@ android {
         }
         release {
             isMinifyEnabled = false
-            signingConfig = signingConfigs.findByName("releaseSign")
-                ?: signingConfigs.getByName("sharedDebug")
+            signingConfig = signingConfigs.getByName("sharedDebug")
             proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
         }
     }
diff --git a/service/src/main/AndroidManifest.xml b/service/src/main/AndroidManifest.xml
index be37583..75b54fe 100644
--- a/service/src/main/AndroidManifest.xml
+++ b/service/src/main/AndroidManifest.xml
@@ -1,5 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
-<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    android:sharedUserId="android.uid.system">
 
     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
@@ -11,6 +12,8 @@
         android:protectionLevel="signature|privileged" />
 
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
+    <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"/>
+    <uses-permission android:name="android.permission.INJECT_EVENTS"/>
 
     <application
         android:label="QsMcuService"
@@ -19,6 +22,7 @@
 
         <service
             android:name=".QsMcuSystemService"
+            android:singleUser="true"
             android:exported="true"
             android:permission="cn.qsjnic.permission.MCU_ACCESS"
             android:foregroundServiceType="dataSync">
@@ -27,15 +31,9 @@
             </intent-filter>
         </service>
 
-        <!-- Dev-only headless trigger to start the service after an idle stop. -->
-        <activity
-            android:name=".DevTriggerActivity"
-            android:exported="true"
-            android:excludeFromRecents="true"
-            android:theme="@android:style/Theme.NoDisplay"/>
-
         <receiver
             android:name=".BootCompletedReceiver"
+            android:singleUser="true"
             android:exported="false">
             <intent-filter>
                 <action android:name="android.intent.action.BOOT_COMPLETED"/>
diff --git a/service/src/main/cpp/mcu_transport.c b/service/src/main/cpp/mcu_transport.c
index 19a1d6a..5f5828a 100644
--- a/service/src/main/cpp/mcu_transport.c
+++ b/service/src/main/cpp/mcu_transport.c
@@ -442,6 +442,8 @@ bool mcu_link_send_data_frame(McuLink *ctx, uint8_t ft, const uint8_t *payload,
     if (seq_out != NULL) {
         *seq_out = seq;
     }
+    MCU_LOGI("[TX] seq=%u ft=0x%02X payload=%zuB sent=%s",
+             (unsigned)seq, ft, payload_len, ok ? "OK" : "FAIL");
     return ok;
 }
 
@@ -720,10 +722,15 @@ bool mcu_link_wait_for_ack(McuLink *ctx, uint8_t expected_seq, int timeout_ms)
         }
         if (deferred_count < ARRAY_SIZE(deferred)) {
             deferred[deferred_count++] = event;
+        } else {
+            MCU_LOGW("[DROP] deferred overflow, dropping ft=0x%02X seq=%u",
+                     event.frame.ft, event.frame.seq);
         }
     }
 
     event_queue_restore_front(&ctx->events, deferred, deferred_count);
+    MCU_LOGW("[TIMEOUT] wait_for_ack expected_seq=%u timeout=%dms deferred=%zu",
+             (unsigned)expected_seq, timeout_ms, deferred_count);
     return false;
 }
 
@@ -780,6 +787,8 @@ bool mcu_link_wait_for_update_frame(McuLink *ctx, int timeout_ms, McuFrame *fram
             return true;
         }
         /* drop non-update frames (already auto-ACKed by reader) */
+        MCU_LOGW("[DROP] wait_for_update dropped ft=0x%02X seq=%u",
+                 event.frame.ft, event.frame.seq);
     }
     return false;
 }
diff --git a/service/src/main/java/com/qsjnic/mcu/service/FrameRouter.java b/service/src/main/java/com/qsjnic/mcu/service/FrameRouter.java
index e3d589a..c9803fd 100644
--- a/service/src/main/java/com/qsjnic/mcu/service/FrameRouter.java
+++ b/service/src/main/java/com/qsjnic/mcu/service/FrameRouter.java
@@ -29,6 +29,7 @@ public final class FrameRouter {
 
     // MsgType constants.
     private static final int MSG_TYPE_PUBLIC = 0x01;
+    private static final int MSG_TYPE_KEY    = 0x02;
     private static final int MSG_TYPE_RADIO  = 0x05;
     private static final int MSG_TYPE_UPDATE = 0x07;
 
@@ -67,6 +68,9 @@ public final class FrameRouter {
             case MSG_TYPE_PUBLIC:
                 handlePublic(frameType, cmd, payload, len);
                 break;
+            case MSG_TYPE_KEY:
+                handleKey(frameType, cmd, payload, len);
+                break;
             case MSG_TYPE_RADIO:
                 handleRadio(frameType, cmd, payload, len);
                 break;
@@ -126,6 +130,18 @@ public final class FrameRouter {
         }
     }
 
+    // ------------------------------------------------------------ Key
+
+    private static void handleKey(int frameType, int cmd, byte[] p, int len) {
+        if (cmd == 0x01 && len >= 2) {
+            Log.i(TAG, String.format("KEY value=0x%02X status=0x%02X",
+                    p[0] & 0xFF, p[1] & 0xFF));
+            return;
+        }
+        Log.i(TAG, String.format("KEY cmd=0x%02X type=0x%02X len=%d",
+                cmd, frameType, len));
+    }
+
     // ------------------------------------------------------------ Radio
 
     private static void handleRadio(int frameType, int cmd, byte[] p, int len) {
diff --git a/service/src/main/java/com/qsjnic/mcu/service/McuBinder.java b/service/src/main/java/com/qsjnic/mcu/service/McuBinder.java
index b5156d5..42ee02d 100644
--- a/service/src/main/java/com/qsjnic/mcu/service/McuBinder.java
+++ b/service/src/main/java/com/qsjnic/mcu/service/McuBinder.java
@@ -2,8 +2,10 @@ package com.qsjnic.mcu.service;
 
 import android.os.RemoteCallbackList;
 import android.os.RemoteException;
+import android.util.Log;
 
 import com.qsjnic.mcu.contract.IMcuFrameListener;
+import com.qsjnic.mcu.contract.IMcuKeyListener;
 import com.qsjnic.mcu.contract.IMcuRadioListener;
 import com.qsjnic.mcu.contract.IMcuService;
 import com.qsjnic.mcu.contract.IMcuUpgradeListener;
@@ -20,24 +22,29 @@ import com.qsjnic.mcu.domain.Cmds;
 import com.qsjnic.mcu.domain.Commands;
 import com.qsjnic.mcu.domain.MsgType;
 
+import com.qsjnic.mcu.service.feature.KeyFeature;
 import com.qsjnic.mcu.service.feature.RadioFeature;
 import com.qsjnic.mcu.service.feature.UpgradeFeature;
 import com.qsjnic.mcu.service.feature.VehicleFeature;
 
 public class McuBinder extends IMcuService.Stub {
 
+    private static final String TAG = "McuBinder";
+
     private static final int OK              = McuErrorCode.OK;
     private static final int ERR_LINK_DOWN   = McuErrorCode.ERR_LINK_DOWN;
     private static final int ERR_INVALID_ARG = McuErrorCode.ERR_INVALID_ARG;
 
     private final VehicleFeature vehicle;
+    private final KeyFeature key;
     private final RadioFeature radio;
     private final UpgradeFeature upgrade;
     private final RemoteCallbackList<IMcuFrameListener> frameListeners;
 
-    public McuBinder(VehicleFeature vehicle, RadioFeature radio, UpgradeFeature upgrade,
+    public McuBinder(VehicleFeature vehicle, KeyFeature key, RadioFeature radio, UpgradeFeature upgrade,
                      RemoteCallbackList<IMcuFrameListener> frameListeners) {
         this.vehicle = vehicle;
+        this.key = key;
         this.radio = radio;
         this.upgrade = upgrade;
         this.frameListeners = frameListeners;
@@ -107,6 +114,32 @@ public class McuBinder extends IMcuService.Stub {
         int seq = McuLink.get().sendAndWaitAck(MsgType.PUBLIC, Cmds.Public.AMP_PWR, Commands.Public.ampPower(on), 1500); return seq >= 0 ? OK : McuErrorCode.ERR_TIMEOUT;
     }
 
+    // ---- Key ----
+
+    @Override
+    public void registerKeyListener(IMcuKeyListener cb) throws RemoteException {
+        key.register(cb);
+    }
+
+    @Override
+    public void unregisterKeyListener(IMcuKeyListener cb) throws RemoteException {
+        key.unregister(cb);
+    }
+
+    @Override
+    public int sendKeyEvent(int keyValue, int keyStatus) throws RemoteException {
+        try {
+            byte[] payload = Commands.Key.key(keyValue, keyStatus);
+            if (!McuLink.get().isLinkAlive()) return ERR_LINK_DOWN;
+            int seq = McuLink.get().sendAndWaitAck(MsgType.KEY, Cmds.Key.A2M_KEY, payload, 1500);
+            Log.i(TAG, String.format("sendKeyEvent key=0x%02X status=0x%02X seq=%d", keyValue, keyStatus, seq));
+            return seq >= 0 ? OK : McuErrorCode.ERR_TIMEOUT;
+        } catch (IllegalArgumentException e) {
+            Log.w(TAG, "sendKeyEvent invalid arg: " + e.getMessage());
+            return ERR_INVALID_ARG;
+        }
+    }
+
     // ---- Radio ----
 
     @Override
@@ -126,8 +159,13 @@ public class McuBinder extends IMcuService.Stub {
 
     @Override
     public int setBand(int band) throws RemoteException {
+        return setBandWithFreq(band, 0);
+    }
+
+    @Override
+    public int setBandWithFreq(int band, int freq) throws RemoteException {
         try {
-            byte[] payload = Commands.Radio.setBand(band);
+            byte[] payload = Commands.Radio.setBand(band, freq);
             if (!McuLink.get().isLinkAlive()) return ERR_LINK_DOWN;
             int seq = McuLink.get().sendAndWaitAck(MsgType.RADIO, Cmds.Radio.SET_BAND, payload, 1500); return seq >= 0 ? OK : McuErrorCode.ERR_TIMEOUT;
         } catch (IllegalArgumentException e) {
@@ -232,6 +270,12 @@ public class McuBinder extends IMcuService.Stub {
         return UpgradeManager.getInstance().sendBootUpgradeChecksum(checksum);
     }
 
+    @Override
+    public int sendBootUpgradeChecksumMd5(byte[] md5) throws RemoteException {
+        if (!McuLink.get().isLinkAlive()) return ERR_LINK_DOWN;
+        return UpgradeManager.getInstance().sendBootUpgradeChecksumMd5(md5);
+    }
+
     // ---- Public A2M ----
 
     @Override
@@ -333,8 +377,18 @@ public class McuBinder extends IMcuService.Stub {
 
     @Override
     public int requestTripClear() throws RemoteException {
-        if (!McuLink.get().isLinkAlive()) return ERR_LINK_DOWN;
-        int seq = McuLink.get().sendAndWaitAck(MsgType.PUBLIC, Cmds.Public.TRIP_CLEAR, Commands.Public.reqTripClear(), 1500); return seq >= 0 ? OK : McuErrorCode.ERR_TIMEOUT;
+        return requestTripClearType(1);
+    }
+
+    @Override
+    public int requestTripClearType(int type) throws RemoteException {
+        try {
+            byte[] payload = Commands.Public.reqTripClear(type);
+            if (!McuLink.get().isLinkAlive()) return ERR_LINK_DOWN;
+            int seq = McuLink.get().sendAndWaitAck(MsgType.PUBLIC, Cmds.Public.TRIP_CLEAR, payload, 1500); return seq >= 0 ? OK : McuErrorCode.ERR_TIMEOUT;
+        } catch (IllegalArgumentException e) {
+            return ERR_INVALID_ARG;
+        }
     }
 
     @Override
@@ -361,9 +415,24 @@ public class McuBinder extends IMcuService.Stub {
 
     @Override
     public int setGpsInfo(int latE6, int lonE6, int speedKmh, int heading) throws RemoteException {
-        if (!McuLink.get().isLinkAlive()) return ERR_LINK_DOWN;
-        byte[] payload = Commands.Public.gpsInfo(latE6, lonE6, speedKmh, heading);
-        int seq = McuLink.get().sendAndWaitAck(MsgType.PUBLIC, Cmds.Public.GPS_INFO, payload, 1500); return seq >= 0 ? OK : McuErrorCode.ERR_TIMEOUT;
+        try {
+            byte[] payload = Commands.Public.gpsInfo(latE6, lonE6, speedKmh, heading);
+            if (!McuLink.get().isLinkAlive()) return ERR_LINK_DOWN;
+            int seq = McuLink.get().sendAndWaitAck(MsgType.PUBLIC, Cmds.Public.GPS_INFO, payload, 1500); return seq >= 0 ? OK : McuErrorCode.ERR_TIMEOUT;
+        } catch (IllegalArgumentException e) {
+            return ERR_INVALID_ARG;
+        }
+    }
+
+    @Override
+    public int setGpsInfoFull(int moduleStatus, int lonPpm, int lonStatus, int latPpm, int latStatus) throws RemoteException {
+        try {
+            byte[] payload = Commands.Public.gpsInfoFull(moduleStatus, lonPpm, lonStatus, latPpm, latStatus);
+            if (!McuLink.get().isLinkAlive()) return ERR_LINK_DOWN;
+            int seq = McuLink.get().sendAndWaitAck(MsgType.PUBLIC, Cmds.Public.GPS_INFO, payload, 1500); return seq >= 0 ? OK : McuErrorCode.ERR_TIMEOUT;
+        } catch (IllegalArgumentException e) {
+            return ERR_INVALID_ARG;
+        }
     }
 
     @Override
diff --git a/service/src/main/java/com/qsjnic/mcu/service/QsMcuSystemService.java b/service/src/main/java/com/qsjnic/mcu/service/QsMcuSystemService.java
index d1de110..24f0fb5 100644
--- a/service/src/main/java/com/qsjnic/mcu/service/QsMcuSystemService.java
+++ b/service/src/main/java/com/qsjnic/mcu/service/QsMcuSystemService.java
@@ -22,6 +22,8 @@ import com.qsjnic.mcu.contract.IMcuService;
 import com.qsjnic.mcu.contract.IMcuFrameListener;
 
 import com.qsjnic.mcu.service.feature.DomainBus;
+import com.qsjnic.mcu.service.feature.KeyFeature;
+import com.qsjnic.mcu.service.feature.KeyInjector;
 import com.qsjnic.mcu.service.feature.VehicleFeature;
 import com.qsjnic.mcu.service.feature.RadioFeature;
 import com.qsjnic.mcu.service.feature.UpgradeFeature;
@@ -33,7 +35,7 @@ import com.qsjnic.mcu.service.feature.UpgradeFeature;
 public class QsMcuSystemService extends Service {
 
     private static final String TAG = "QsMcuSystemService";
-    private static QsMcuSystemService sInstance;
+    private volatile static QsMcuSystemService sInstance;
 
     private static final String SERIAL_PORT = "/dev/ttyS7";
     private static final int    SERIAL_BAUD = 460800;
@@ -49,6 +51,7 @@ public class QsMcuSystemService extends Service {
 
     private DomainBus bus;
     private VehicleFeature vehicle;
+    private KeyFeature key;
     private RadioFeature radio;
     private UpgradeFeature upgrade;
 
@@ -57,22 +60,26 @@ public class QsMcuSystemService extends Service {
     @Override
     public void onCreate() {
         super.onCreate();
-        sInstance = this;
 
         vehicle = new VehicleFeature(frameListeners);
+        key     = new KeyFeature();
+        key.setInjector(new KeyInjector(this));
         radio   = new RadioFeature(frameListeners);
         upgrade = new UpgradeFeature(frameListeners);
         bus = new DomainBus();
         bus.register(vehicle);
+        bus.register(key);
         bus.register(radio);
         bus.register(upgrade);
+        // Expose singleton only after all fields are initialized.
+        sInstance = this;
         FrameRouter.setBus(bus);
         Log.i(TAG, "onCreate"
                 + " pid=" + Process.myPid()
                 + " uid=" + Process.myUid()
                 + " thread=" + Thread.currentThread().getName());
 
-        binder = new McuBinder(vehicle, radio, upgrade, frameListeners);
+        binder = new McuBinder(vehicle, key, radio, upgrade, frameListeners);
 
         // Foreground service notification (required on Android 8+ to stay alive).
         startForeground(NOTIF_ID, buildForegroundNotification());
@@ -180,8 +187,12 @@ public class QsMcuSystemService extends Service {
         Log.i(TAG, "onDestroy");
         sInstance = null;
         stopForeground(true);
-        McuLink.get().stop();
-        McuLink.get().close();
+        // sendCloseAndStop() already performs wait -> sendClose -> close.
+        McuLink.get().sendCloseAndStop();
+        // Stop background HandlerThreads so their Loopers don't leak across
+        // service restarts.
+        FrameRouter.quit();
+        UpgradeManager.getInstance().shutdown();
         super.onDestroy();
     }
 
@@ -221,6 +232,7 @@ public class QsMcuSystemService extends Service {
 
         pw.println("----- Listeners -----");
         pw.println("Vehicle listeners: " + vehicle.listenerCount());
+        pw.println("Key listeners:     " + key.listenerCount());
         pw.println("Radio listeners:   " + radio.listenerCount());
         pw.println("Upgrade listeners: " + upgrade.listenerCount());
     }
diff --git a/service/src/main/java/com/qsjnic/mcu/service/UpgradeManager.java b/service/src/main/java/com/qsjnic/mcu/service/UpgradeManager.java
index 5b57775..608799e 100644
--- a/service/src/main/java/com/qsjnic/mcu/service/UpgradeManager.java
+++ b/service/src/main/java/com/qsjnic/mcu/service/UpgradeManager.java
@@ -13,6 +13,9 @@ import com.qsjnic.mcu.contract.IMcuUpgradeListener;
 import com.qsjnic.mcu.contract.McuErrorCode;
 import com.qsjnic.mcu.contract.UpgradeProgress;
 import com.qsjnic.mcu.contract.UpgradeRequest;
+import com.qsjnic.mcu.domain.Commands;
+
+import java.io.File;
 
 /**
  * MCU OTA upgrade manager — thin wrapper around native {@link McuLink#runUpgrade}.
@@ -116,7 +119,20 @@ public final class UpgradeManager {
 
     public int startBootUpgrade(@Nullable UpgradeRequest req) {
         if (!McuLink.get().isLinkAlive()) return ERR_LINK_DOWN;
-        return McuLink.get().sendBusinessFrame(0x07, 0x84, null) >= 0 ? ERR_OK : ERR_LINK_DOWN;
+        if (req == null || req.binFilePath == null || req.binFilePath.isEmpty()) {
+            return ERR_INVALID_ARG;
+        }
+        File bin = new File(req.binFilePath);
+        if (!bin.isFile() || bin.length() <= 0) return McuErrorCode.ERR_FILE;
+        String project = req.projectId == null || req.projectId.isEmpty() ? "10040" : req.projectId;
+        byte[] payload;
+        try {
+            payload = Commands.Update.bootBegin(bin.length(), project);
+        } catch (IllegalArgumentException e) {
+            return ERR_INVALID_ARG;
+        }
+        int seq = McuLink.get().sendAndWaitAck(0x07, 0x84, payload, 1500);
+        return seq >= 0 ? ERR_OK : McuErrorCode.ERR_TIMEOUT;
     }
 
     public int sendBootUpgradeData(byte[] chunk, int index) {
@@ -126,16 +142,24 @@ public final class UpgradeManager {
         payload[0] = (byte) ((index >> 8) & 0xFF);
         payload[1] = (byte) ( index       & 0xFF);
         System.arraycopy(chunk, 0, payload, 2, chunk.length);
-        return McuLink.get().sendBusinessFrame(0x07, 0x85, payload) >= 0 ? ERR_OK : ERR_LINK_DOWN;
+        int seq = McuLink.get().sendAndWaitAck(0x07, 0x85, payload, 1500);
+        return seq >= 0 ? ERR_OK : McuErrorCode.ERR_TIMEOUT;
     }
 
     public int sendBootUpgradeChecksum(int checksum) {
+        return ERR_INVALID_ARG;
+    }
+
+    public int sendBootUpgradeChecksumMd5(byte[] md5) {
         if (!McuLink.get().isLinkAlive()) return ERR_LINK_DOWN;
-        byte[] payload = new byte[] {
-                (byte) ((checksum >> 24) & 0xFF), (byte) ((checksum >> 16) & 0xFF),
-                (byte) ((checksum >> 8) & 0xFF),  (byte) ( checksum       & 0xFF)
-        };
-        return McuLink.get().sendBusinessFrame(0x07, 0x86, payload) >= 0 ? ERR_OK : ERR_LINK_DOWN;
+        byte[] payload;
+        try {
+            payload = Commands.Update.bootChecksumMd5(md5);
+        } catch (IllegalArgumentException e) {
+            return ERR_INVALID_ARG;
+        }
+        int seq = McuLink.get().sendAndWaitAck(0x07, 0x86, payload, 1500);
+        return seq >= 0 ? ERR_OK : McuErrorCode.ERR_TIMEOUT;
     }
 
     // ---------------------------------------------------- called from FrameRouter
diff --git a/service/src/main/java/com/qsjnic/mcu/service/feature/KeyFeature.java b/service/src/main/java/com/qsjnic/mcu/service/feature/KeyFeature.java
new file mode 100644
index 0000000..83a3599
--- /dev/null
+++ b/service/src/main/java/com/qsjnic/mcu/service/feature/KeyFeature.java
@@ -0,0 +1,67 @@
+package com.qsjnic.mcu.service.feature;
+
+import android.content.Context;
+import android.os.RemoteCallbackList;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.qsjnic.mcu.contract.IMcuKeyListener;
+import com.qsjnic.mcu.domain.Cmds;
+import com.qsjnic.mcu.domain.Decoders;
+import com.qsjnic.mcu.domain.MsgType;
+
+public final class KeyFeature implements Feature {
+
+    private static final String TAG = "KeyFeature";
+
+    private final RemoteCallbackList<IMcuKeyListener> typedListeners = new RemoteCallbackList<>();
+    private KeyInjector injector;
+
+    @Override public boolean accepts(int msgType) {
+        return msgType == MsgType.KEY;
+    }
+
+    @Override public void onFrame(int frameType, int msgType, int cmd, byte[] body) {
+        if (cmd == Cmds.Key.M2A_KEY) {
+            int[] v = Decoders.parseKeyControl(body);
+            if (v != null) broadcastKeyEvent(v[0], v[1]);
+            return;
+        }
+        Log.d(TAG, String.format("unknown KEY cmd=0x%02X", cmd));
+    }
+
+    public void register(IMcuKeyListener cb) {
+        typedListeners.register(cb);
+    }
+
+    public void unregister(IMcuKeyListener cb) {
+        typedListeners.unregister(cb);
+    }
+
+    /** Set the optional KeyInjector for system-level injection. */
+    public void setInjector(KeyInjector injector) {
+        this.injector = injector;
+    }
+
+    public int listenerCount() {
+        return typedListeners.getRegisteredCallbackCount();
+    }
+
+    private interface OneWayCall { void call(IMcuKeyListener cb) throws RemoteException; }
+
+    private void broadcastTyped(OneWayCall call) {
+        int n = typedListeners.beginBroadcast();
+        for (int i = 0; i < n; i++) {
+            try { call.call(typedListeners.getBroadcastItem(i)); }
+            catch (RemoteException ignored) {}
+        }
+        typedListeners.finishBroadcast();
+    }
+
+    private void broadcastKeyEvent(int keyValue, int keyStatus) {
+        broadcastTyped(cb -> cb.onKeyEvent(keyValue, keyStatus));
+        if (injector != null) {
+            injector.inject(keyValue, keyStatus);
+        }
+    }
+}
diff --git a/service/src/main/java/com/qsjnic/mcu/service/feature/KeyInjector.java b/service/src/main/java/com/qsjnic/mcu/service/feature/KeyInjector.java
new file mode 100644
index 0000000..592c13e
--- /dev/null
+++ b/service/src/main/java/com/qsjnic/mcu/service/feature/KeyInjector.java
@@ -0,0 +1,208 @@
+package com.qsjnic.mcu.service.feature;
+
+import android.content.Context;
+import android.hardware.input.InputManager;
+import android.os.SystemClock;
+import android.util.Log;
+import android.view.InputEvent;
+import android.view.KeyEvent;
+
+import androidx.annotation.NonNull;
+
+import java.lang.reflect.Method;
+
+/**
+ * 将 MCU Key 域按键事件注入 Android Input 子系统。
+ * <p>
+ * 调用方：KeyFeature（运行在 FrameRouter 的 HandlerThread 上）。
+ * 权限：需要 android.permission.INJECT_EVENTS，由 service 模块 manifest 声明。
+ * <p>
+ * 设计原则：
+ * - 保留现有 Listener 回调通道（给已集成 SDK 的应用精确控制）
+ * - 注入通道补充（给未集成 SDK 的系统应用标准 KeyEvent）
+ * - 两者并存，互不干扰
+ * <p>
+ * injectInputEvent 是 @hide API，通过反射调用。
+ */
+public final class KeyInjector {
+
+    private static final String TAG = "KeyInjector";
+
+    // ------ MCU keyValue 常量（与 McuKeyClient 保持一致） ------
+    public static final int KEY_VALUE_NONE            = 0x00;
+    public static final int KEY_VALUE_POWER           = 0x01;
+    public static final int KEY_VALUE_HOME            = 0x02;
+    public static final int KEY_VALUE_VOICE           = 0x03;
+    public static final int KEY_VALUE_MUSIC           = 0x04;
+    public static final int KEY_VALUE_RADIO           = 0x05;
+    public static final int KEY_VALUE_TEL             = 0x06;
+    public static final int KEY_VALUE_TFT             = 0x07;
+    public static final int KEY_VALUE_VOL_DEC         = 0x08;
+    public static final int KEY_VALUE_VOL_INC         = 0x09;
+    public static final int KEY_VALUE_MUTE            = 0x0C;
+    public static final int KEY_VALUE_MODE            = 0x0D;
+    public static final int KEY_VALUE_PREV            = 0x0E;
+    public static final int KEY_VALUE_NEXT            = 0x0F;
+    public static final int KEY_VALUE_ANSWER          = 0x18;
+    public static final int KEY_VALUE_REJECT          = 0x19;
+    public static final int KEY_VALUE_CAMERA          = 0x25;
+
+    // ------ MCU keyStatus 常量 ------
+    public static final int KEY_STATUS_RELEASE        = 0x00;
+    public static final int KEY_STATUS_SHORT_PRESS    = 0x01;
+    public static final int KEY_STATUS_STUCK          = 0x02;
+    public static final int KEY_STATUS_ENCODER_LEFT   = 0x03;
+    public static final int KEY_STATUS_ENCODER_RIGHT  = 0x04;
+
+    // ------ MCU keyValue → Android KeyEvent keyCode 映射 ------
+
+    private static int toAndroidKeyCode(int keyValue) {
+        switch (keyValue) {
+            case KEY_VALUE_VOL_DEC: return KeyEvent.KEYCODE_VOLUME_DOWN;
+            case KEY_VALUE_VOL_INC: return KeyEvent.KEYCODE_VOLUME_UP;
+            case KEY_VALUE_MUTE:    return KeyEvent.KEYCODE_VOLUME_MUTE;
+            case KEY_VALUE_HOME:    return KeyEvent.KEYCODE_HOME;
+            case KEY_VALUE_VOICE:   return KeyEvent.KEYCODE_VOICE_ASSIST;
+            case KEY_VALUE_PREV:    return KeyEvent.KEYCODE_MEDIA_PREVIOUS;
+            case KEY_VALUE_NEXT:    return KeyEvent.KEYCODE_MEDIA_NEXT;
+            case KEY_VALUE_ANSWER:  return KeyEvent.KEYCODE_CALL;
+            case KEY_VALUE_REJECT:  return KeyEvent.KEYCODE_ENDCALL;
+            case KEY_VALUE_MODE:    return KeyEvent.KEYCODE_APP_SWITCH;
+            case KEY_VALUE_MUSIC:   return KeyEvent.KEYCODE_MUSIC;
+            case KEY_VALUE_POWER:   return KeyEvent.KEYCODE_POWER;
+            case KEY_VALUE_CAMERA:  return KeyEvent.KEYCODE_CAMERA;
+            default:
+                Log.d(TAG, "unmapped keyValue=0x" + Integer.toHexString(keyValue));
+                return -1;
+        }
+    }
+
+    /** 编码器方向由 keyValue 决定：VOL_DEC=左旋（音量减），VOL_INC=右旋（音量加） */
+    private static int toEncoderKeyCode(int keyValue) {
+        switch (keyValue) {
+            case KEY_VALUE_VOL_DEC: return KeyEvent.KEYCODE_VOLUME_DOWN;
+            case KEY_VALUE_VOL_INC: return KeyEvent.KEYCODE_VOLUME_UP;
+            default: return -1;
+        }
+    }
+
+    private final InputManager inputManager;
+    private final Method injectMethod;
+    private boolean enabled = true;
+
+    /** Track the currently pressed key for correct downTime pairing on STUCK/RELEASE. */
+    private int pendingKeyCode = -1;
+    private long pendingDownTime = -1;
+
+    public KeyInjector(@NonNull Context context) {
+        this.inputManager = (InputManager) context.getSystemService(Context.INPUT_SERVICE);
+
+        // 反射获取 injectInputEvent — @hide API
+        // AOSP 中实际存在的签名只有 (InputEvent, int)，自 API 16 起即有。
+        Method m = null;
+        if (inputManager != null) {
+            try { m = InputManager.class.getMethod("injectInputEvent", InputEvent.class, int.class); } catch (NoSuchMethodException ignored) {}
+            if (m == null) {
+                Log.w(TAG, "injectInputEvent(InputEvent, int) not found");
+            }
+        }
+        this.injectMethod = m;
+    }
+
+    /** 全局开关 */
+    public void setEnabled(boolean on) {
+        this.enabled = on;
+    }
+
+    /**
+     * 注入一次按键事件。
+     *
+     * @param keyValue  MCU 键值
+     * @param keyStatus MCU 键状态
+     */
+    public void inject(int keyValue, int keyStatus) {
+        if (!enabled) return;
+        if (inputManager == null || injectMethod == null) {
+            Log.w(TAG, "InputManager or injectMethod not available, skip inject");
+            return;
+        }
+
+        // 编码器事件
+        if (keyStatus == KEY_STATUS_ENCODER_LEFT
+                || keyStatus == KEY_STATUS_ENCODER_RIGHT) {
+            injectEncoder(keyValue, keyStatus);
+            return;
+        }
+
+        int keyCode = toAndroidKeyCode(keyValue);
+        if (keyCode < 0) return;
+
+        switch (keyStatus) {
+            case KEY_STATUS_SHORT_PRESS: {
+                // 新按键序列，总是分配新的 downTime
+                long now = SystemClock.uptimeMillis();
+                injectKeyEvent(keyCode, KeyEvent.ACTION_DOWN, 0, now);
+                injectKeyEvent(keyCode, KeyEvent.ACTION_UP, 0, now);
+                pendingKeyCode = -1;
+                pendingDownTime = -1;
+                break;
+            }
+            case KEY_STATUS_STUCK: {
+                // 复用同一按键首次按下的 downTime，避免系统认为是不间断的新按下
+                if (keyCode != pendingKeyCode) {
+                    // 首次按下或换键了
+                    pendingKeyCode = keyCode;
+                    pendingDownTime = SystemClock.uptimeMillis();
+                }
+                injectKeyEvent(keyCode, KeyEvent.ACTION_DOWN, 0, pendingDownTime);
+                break;
+            }
+            case KEY_STATUS_RELEASE: {
+                if (keyCode == pendingKeyCode && pendingDownTime >= 0) {
+                    injectKeyEvent(keyCode, KeyEvent.ACTION_UP, 0, pendingDownTime);
+                } else {
+                    // 不匹配或已释放过，fallback：新分配 downTime
+                    long now = SystemClock.uptimeMillis();
+                    injectKeyEvent(keyCode, KeyEvent.ACTION_UP, 0, now);
+                }
+                pendingKeyCode = -1;
+                pendingDownTime = -1;
+                break;
+            }
+            default:
+                Log.d(TAG, "unhandled keyStatus=0x" + Integer.toHexString(keyStatus));
+        }
+    }
+
+    private void injectEncoder(int keyValue, int keyStatus) {
+        int keyCode = toEncoderKeyCode(keyValue);
+        if (keyCode < 0) return;
+        long now = SystemClock.uptimeMillis();
+        injectKeyEvent(keyCode, KeyEvent.ACTION_DOWN, 0, now);
+        injectKeyEvent(keyCode, KeyEvent.ACTION_UP, 0, now);
+    }
+
+    private void injectKeyEvent(int keyCode, int action, int repeatCount, long downTime) {
+        try {
+            long now = SystemClock.uptimeMillis();
+            KeyEvent event = new KeyEvent(downTime, now, action, keyCode, repeatCount);
+            int paramCount = injectMethod.getParameterCount();
+            if (paramCount == 1) {
+                // injectInputEvent(InputEvent)
+                injectMethod.invoke(inputManager, event);
+            } else if (paramCount == 2) {
+                Class<?> secondParam = injectMethod.getParameterTypes()[1];
+                if (secondParam == int.class) {
+                    // injectInputEvent(InputEvent, int) — API 28+
+                    injectMethod.invoke(inputManager, event, 0);
+                } else {
+                    // injectInputEvent(InputEvent, boolean)
+                    injectMethod.invoke(inputManager, event, true);
+                }
+            }
+            Log.v(TAG, "inject keyCode=" + keyCode + " action=" + action + " repeat=" + repeatCount);
+        } catch (Exception e) {
+            Log.w(TAG, "injectInputEvent failed keyCode=" + keyCode + " action=" + action, e);
+        }
+    }
+}
diff --git a/service/src/main/java/com/qsjnic/mcu/service/feature/RadioFeature.java b/service/src/main/java/com/qsjnic/mcu/service/feature/RadioFeature.java
index 0fe2431..df5c593 100644
--- a/service/src/main/java/com/qsjnic/mcu/service/feature/RadioFeature.java
+++ b/service/src/main/java/com/qsjnic/mcu/service/feature/RadioFeature.java
@@ -35,9 +35,18 @@ public final class RadioFeature implements Feature {
             case Cmds.Radio.M2A_FREQ:
                 handleFreq(body);
                 break;
+            case Cmds.Radio.M2A_PS_PTY:
+                handlePsPty(body);
+                break;
+            case Cmds.Radio.M2A_RT:
+                handleRadioText(body);
+                break;
             case Cmds.Radio.M2A_TA_PTY_TIPS:
                 handleTaPtyTips(body);
                 break;
+            case Cmds.Radio.M2A_WORK_STATUS:
+                handleWorkStatus(body);
+                break;
             default:
                 Log.d(TAG, String.format("unknown RADIO cmd=0x%02X", cmd));
         }
@@ -84,6 +93,25 @@ public final class RadioFeature implements Feature {
         broadcast();
     }
 
+    private void handlePsPty(byte[] p) {
+        Object[] v = Decoders.parsePsPty(p);
+        if (v == null) return;
+        final int pi = (Integer) v[0], pty = (Integer) v[1];
+        final String ps = (String) v[2];
+        broadcastTyped(cb -> cb.onPsPty(pi, pty, ps));
+    }
+
+    private void handleRadioText(byte[] p) {
+        String rt = Decoders.parseRadioText(p);
+        broadcastTyped(cb -> cb.onRadioText(rt));
+    }
+
+    private void handleWorkStatus(byte[] p) {
+        int status = Decoders.parseWorkStatus(p);
+        if (status < 0) return;
+        broadcastTyped(cb -> cb.onWorkStatus(status));
+    }
+
     private void handleTaPtyTips(byte[] p) {
         int[] v = Decoders.parseTaPtyTips(p);
         if (v == null) return;
diff --git a/service/src/main/java/com/qsjnic/mcu/service/feature/VehicleFeature.java b/service/src/main/java/com/qsjnic/mcu/service/feature/VehicleFeature.java
index ffa6a99..aa34333 100644
--- a/service/src/main/java/com/qsjnic/mcu/service/feature/VehicleFeature.java
+++ b/service/src/main/java/com/qsjnic/mcu/service/feature/VehicleFeature.java
@@ -66,6 +66,9 @@ public final class VehicleFeature implements Feature {
             case Cmds.Public.M2A_BP_ABNORMAL:
                 broadcastBpAbnormal(Decoders.parseBpAbnormal(body));
                 break;
+            case Cmds.Public.M2A_BATT_INFO:
+                broadcastBattInfo(Decoders.parseBattVoltage(body), Decoders.parseBattPercent(body));
+                break;
             case Cmds.Public.M2A_TFT_DUTY:
                 snapshot.tftDutyPercent = Decoders.parseBacklight(body);
                 broadcastVehicle();
@@ -206,6 +209,7 @@ public final class VehicleFeature implements Feature {
     private void broadcastComTest(byte[] d)     { broadcastTyped(cb -> cb.onComTest(d != null ? d.clone() : null)); }
     private void broadcastTestInfo(byte[] d)    { broadcastTyped(cb -> cb.onTestInfo(d != null ? d.clone() : null)); }
     private void broadcastComTestResponse(byte[] d) { broadcastTyped(cb -> cb.onComTestResponse(d != null ? d.clone() : null)); }
+    private void broadcastBattInfo(int v, int p){ broadcastTyped(cb -> cb.onBattInfo(v, p)); }
     private void broadcastReqPwrOff()           { broadcastTyped(IMcuVehicleListener::onReqPwrOff); }
     private void broadcastStopCharge()          { broadcastTyped(IMcuVehicleListener::onStopCharge); }
     private void broadcastRunNormally()         { broadcastTyped(IMcuVehicleListener::onRunNormally); }
diff --git a/service/src/test/java/com/qsjnic/mcu/service/feature/KeyFeatureTest.java b/service/src/test/java/com/qsjnic/mcu/service/feature/KeyFeatureTest.java
new file mode 100644
index 0000000..5d8be70
--- /dev/null
+++ b/service/src/test/java/com/qsjnic/mcu/service/feature/KeyFeatureTest.java
@@ -0,0 +1,25 @@
+package com.qsjnic.mcu.service.feature;
+
+import com.qsjnic.mcu.domain.Cmds;
+import com.qsjnic.mcu.domain.MsgType;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+public class KeyFeatureTest {
+
+    @Test
+    public void acceptsOnlyKey() {
+        KeyFeature f = new KeyFeature();
+        assertTrue(f.accepts(MsgType.KEY));
+        assertFalse(f.accepts(MsgType.PUBLIC));
+        assertFalse(f.accepts(MsgType.RADIO));
+    }
+
+    @Test
+    public void keyFrame_doesNotCrash() {
+        KeyFeature f = new KeyFeature();
+        f.onFrame(0x06, MsgType.KEY, Cmds.Key.M2A_KEY, new byte[] { 0x01, 0x01 });
+    }
+}
diff --git a/service/src/test/java/com/qsjnic/mcu/service/feature/VehicleFeatureTest.java b/service/src/test/java/com/qsjnic/mcu/service/feature/VehicleFeatureTest.java
index a332ac8..c4c0685 100644
--- a/service/src/test/java/com/qsjnic/mcu/service/feature/VehicleFeatureTest.java
+++ b/service/src/test/java/com/qsjnic/mcu/service/feature/VehicleFeatureTest.java
@@ -13,8 +13,8 @@ public class VehicleFeatureTest {
     @Test
     public void voltageFrame_updatesSnapshot() {
         VehicleFeature f = new VehicleFeature(null);
-        f.onFrame(0x06, MsgType.PUBLIC, Cmds.Public.M2A_VOLTAGE, new byte[]{0x30, (byte)0xD4});
-        assertEquals(0x30D4, f.snapshot().voltageMv);
+        f.onFrame(0x06, MsgType.PUBLIC, Cmds.Public.M2A_VOLTAGE, new byte[]{0x00, (byte)0x80});
+        assertEquals(12800, f.snapshot().voltageMv);
     }
 
     @Test
@@ -53,6 +53,7 @@ public class VehicleFeatureTest {
     public void acceptsOnlyPublic() {
         VehicleFeature f = new VehicleFeature(null);
         assertTrue(f.accepts(MsgType.PUBLIC));
+        assertFalse(f.accepts(MsgType.KEY));
         assertFalse(f.accepts(MsgType.RADIO));
         assertFalse(f.accepts(MsgType.UPDATE));
     }
