/* Copyright (C) 2024 José Rebelo This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.oppo; import org.apache.commons.lang3.ArrayUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoHeadphonesPreferences; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.BatteryState; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.OppoCommand; import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigSide; import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigType; import nodomain.freeyourgadget.gadgetbridge.service.devices.oppo.commands.TouchConfigValue; import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol; import nodomain.freeyourgadget.gadgetbridge.util.preferences.DevicePrefs; public class OppoHeadphonesProtocol extends GBDeviceProtocol { private static final Logger LOG = LoggerFactory.getLogger(OppoHeadphonesProtocol.class); public static final byte CMD_PREAMBLE = (byte) 0xaa; private int seqNum = 0; protected OppoHeadphonesProtocol(final GBDevice device) { super(device); } @Override public GBDeviceEvent[] decodeResponse(final byte[] responseData) { final List events = new ArrayList<>(); final ByteBuffer buf = ByteBuffer.wrap(responseData); while (buf.position() < buf.limit()) { final byte preamble = buf.get(); if (preamble != CMD_PREAMBLE) { LOG.warn("Unexpected preamble {}", preamble); continue; } final int totalLength = buf.get() & 0xff; if (buf.limit() - buf.position() < totalLength) { LOG.error("Got partial response with {} bytes, expected {}", buf.limit() - buf.position(), totalLength); break; } final byte[] singleResponse = new byte[totalLength + 2]; buf.position(buf.position() - 2); buf.get(singleResponse); events.addAll(handleSingleResponse(singleResponse)); } return events.toArray(new GBDeviceEvent[0]); } private static List handleSingleResponse(final byte[] responseData) { final List events = new ArrayList<>(); final ByteBuffer responseBuf = ByteBuffer.wrap(responseData).order(ByteOrder.LITTLE_ENDIAN); final byte preamble = responseBuf.get(); if (preamble != CMD_PREAMBLE) { LOG.error("Unexpected preamble {}", preamble); return Collections.emptyList(); } final int totalLength = responseBuf.get() & 0xff; if (responseData.length != totalLength + 2) { LOG.error("Invalid number of bytes {}, expected {}", responseData.length, totalLength + 2); return Collections.emptyList(); } final short zero = responseBuf.getShort(); if (zero != 0 && zero != 4) { // 0 on oppo, 4 on realme? LOG.warn("Unexpected bytes: {}, expected 0 or 4", zero); } final short code = responseBuf.getShort(); final OppoCommand command = OppoCommand.fromCode(code); if (command == null) { LOG.warn("Unknown command code {}", String.format(Locale.ROOT, "0x%04x", code)); return Collections.emptyList(); } final int seq = responseBuf.get(); final short payloadLength = responseBuf.getShort(); final byte[] payload = new byte[payloadLength]; responseBuf.get(payload); switch (command) { case BATTERY_RET: { if (payload[0] != 0) { LOG.error("Unknown battery ret {}", payload[0]); break; } events.addAll(parseBattery(payload)); break; } case DEVICE_INFO: { switch (payload[0]) { case 1: // battery events.addAll(parseBattery(payload)); break; case 2: // status LOG.debug("Got status"); // TODO handle break; default: LOG.warn("Unknown device info {}", payload[0]); } break; } case FIRMWARE_RET: { if (payload[0] != 0) { LOG.warn("Unexpected firmware ret {}", payload[0]); break; } final String fwString; if (payload[payload.length - 1] == 0) { fwString = new String(ArrayUtils.subarray(payload, 2, payload.length - 1)).strip(); } else { fwString = new String(ArrayUtils.subarray(payload, 2, payload.length)).strip(); } final String[] parts = fwString.split(","); if (parts.length % 3 != 0) { LOG.warn("Fw parts length {} from '{}' is not divisible by 3", parts.length, fwString); // We need to persist something, otherwise Gb misbehaves final GBDeviceEventVersionInfo eventVersionInfo = new GBDeviceEventVersionInfo(); eventVersionInfo.fwVersion = fwString; eventVersionInfo.hwVersion = GBApplication.getContext().getString(R.string.n_a); events.add(eventVersionInfo); break; } final String[] fwVersionParts = new String[3]; for (int i = 0; i < parts.length; i += 3) { final String versionPart = parts[i]; final String versionType = parts[i + 1]; final String version = parts[i + 2]; if (!"2".equals(versionType)) { continue; // not fw } switch (versionPart) { case "1": fwVersionParts[0] = version; break; case "2": fwVersionParts[1] = version; break; case "3": fwVersionParts[2] = version; break; default: LOG.warn("Unknown firmware version part {}", versionPart); } } final List nonNullParts = new ArrayList<>(fwVersionParts.length); for (int i = 0; i < fwVersionParts.length; i++) { if (fwVersionParts[i] == null) { continue; } nonNullParts.add(fwVersionParts[i]); if (fwVersionParts[i].contains(".")) { // Realme devices have the version already with the dots, repeated multiple times break; } } final String fwVersion = String.join(".", nonNullParts); final GBDeviceEventVersionInfo eventVersionInfo = new GBDeviceEventVersionInfo(); eventVersionInfo.fwVersion = fwVersion; eventVersionInfo.hwVersion = GBApplication.getContext().getString(R.string.n_a); events.add(eventVersionInfo); LOG.debug("Got fw version: {}", fwVersion); break; } case FIND_DEVICE_ACK: { LOG.debug("Got find device ack, status={}", payload[0]); break; } case TOUCH_CONFIG_RET: { if (payload[0] != 0) { LOG.warn("Unknown config ret {}", payload[0]); break; } if ((payload.length - 2) % 4 != 0) { LOG.warn("Unexpected config ret payload size {}", payload.length); break; } final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences(); for (int i = 2; i < payload.length; i += 4) { final int sideCode = payload[i] & 0xff; final int typeCode = BLETypeConversions.toUint16(payload, i + 1); final int valueCode = payload[i + 3] & 0xff; final TouchConfigSide side = TouchConfigSide.fromCode(sideCode); final TouchConfigType type = TouchConfigType.fromCode(typeCode); final TouchConfigValue value = TouchConfigValue.fromCode(valueCode); if (side == null) { LOG.warn("Unknown touch side code {}", sideCode); continue; } if (type == null) { LOG.warn("Unknown touch type code {}", typeCode); continue; } if (value == null) { LOG.warn("Unknown touch value code {}", valueCode); continue; } LOG.debug("Got touch config for {} {} = {}", side, type, value); eventUpdatePreferences.withPreference( OppoHeadphonesPreferences.getKey(side, type), value.name().toLowerCase(Locale.ROOT) ); } events.add(eventUpdatePreferences); break; } case TOUCH_CONFIG_ACK: { LOG.debug("Got config ack, status={}", payload[0]); break; } default: LOG.warn("Unhandled command {}", command); } return events; } private static List parseBattery(final byte[] payload) { final List events = new ArrayList<>(); final int numBatteries = payload[1] & 0xff; for (int i = 2; i < payload.length; i += 2) { if ((payload[i] & 0xff) == 0xff) { continue; } final int batteryIndex = payload[i] - 1; if (batteryIndex < 0 || batteryIndex > 2) { LOG.error("Unknown battery index {}", payload[i]); break; } final int batteryLevel = payload[i + 1] & 0x7f; final BatteryState batteryState = (payload[i + 1] & 0x80) != 0 ? BatteryState.BATTERY_CHARGING : BatteryState.BATTERY_NORMAL; LOG.debug("Got battery {}: {}%, {}", batteryIndex, batteryLevel, batteryState); final GBDeviceEventBatteryInfo eventBatteryInfo = new GBDeviceEventBatteryInfo(); eventBatteryInfo.batteryIndex = batteryIndex; eventBatteryInfo.level = batteryLevel; eventBatteryInfo.state = batteryState; events.add(eventBatteryInfo); } return events; } @Override public byte[] encodeFirmwareVersionReq() { return encodeMessage(OppoCommand.FIRMWARE_GET, new byte[0]); } @Override public byte[] encodeFindDevice(final boolean start) { return encodeMessage(OppoCommand.FIND_DEVICE_REQ, new byte[]{(byte) (start ? 0x01 : 0x00)}); } @Override public byte[] encodeSendConfiguration(final String config) { final DevicePrefs prefs = getDevicePrefs(); if (config.startsWith("oppo_touch__")) { final String[] parts = config.split("__"); final TouchConfigSide side = TouchConfigSide.valueOf(parts[1].toUpperCase(Locale.ROOT)); final TouchConfigType type = TouchConfigType.valueOf(parts[2].toUpperCase(Locale.ROOT)); final String valueCode = prefs.getString(OppoHeadphonesPreferences.getKey(side, type), null); if (valueCode == null) { LOG.warn("Failed to get touch option value for {}/{}", side, type); return super.encodeSendConfiguration(config); } final TouchConfigValue value = TouchConfigValue.valueOf(valueCode.toUpperCase(Locale.ROOT)); LOG.debug("Sending {} {} = {}", side, type, value); final ByteBuffer buf = ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN); buf.put((byte) 0x01); buf.put((byte) side.getCode()); buf.putShort((short) type.getCode()); buf.put((byte) value.getCode()); return encodeMessage(OppoCommand.TOUCH_CONFIG_SET, buf.array()); } return super.encodeSendConfiguration(config); } public byte[] encodeBatteryReq() { return encodeMessage(OppoCommand.BATTERY_REQ, new byte[0]); } public byte[] encodeConfigurationReq() { return encodeMessage(OppoCommand.TOUCH_CONFIG_REQ, new byte[]{0x02, 0x03, 0x01}); } private byte[] encodeMessage(final OppoCommand command, final byte[] payload) { final ByteBuffer buf = ByteBuffer.allocate(9 + payload.length).order(ByteOrder.LITTLE_ENDIAN); buf.put(CMD_PREAMBLE); buf.put((byte) (buf.limit() - 2)); buf.put((byte) 0); buf.put((byte) 0); buf.putShort(command.getCode()); buf.put((byte) seqNum++); buf.putShort((short) payload.length); buf.put(payload); return buf.array(); } }