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("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 [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]} [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", + '', + "```", + "", + "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 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 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 @@ - + @@ -11,6 +12,8 @@ android:protectionLevel="signature|privileged" /> + + @@ -27,15 +31,9 @@ - - - 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 frameListeners; - public McuBinder(VehicleFeature vehicle, RadioFeature radio, UpgradeFeature upgrade, + public McuBinder(VehicleFeature vehicle, KeyFeature key, RadioFeature radio, UpgradeFeature upgrade, RemoteCallbackList 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 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 子系统。 + *

+ * 调用方:KeyFeature(运行在 FrameRouter 的 HandlerThread 上)。 + * 权限:需要 android.permission.INJECT_EVENTS,由 service 模块 manifest 声明。 + *

+ * 设计原则: + * - 保留现有 Listener 回调通道(给已集成 SDK 的应用精确控制) + * - 注入通道补充(给未集成 SDK 的系统应用标准 KeyEvent) + * - 两者并存,互不干扰 + *

+ * 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)); }