/* Copyright (C) 2024 enoint 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.devices.hama.fit6900; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.HashMap; import java.util.Map; import nodomain.freeyourgadget.gadgetbridge.model.Alarm; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; public final class Message { private static byte encodeBoolean(boolean value) { return (byte) ((value) ? 1 : 0); } private static void encodeInt16(byte[] data, int offset, int value) { data[offset + 0] = (byte) (value & 0xFF); data[offset + 1] = (byte) ((value >> 8) & 0xFF); } private static void encodeInt32(byte[] data, int offset, int value) { data[offset + 0] = (byte) (value & 0xFF); data[offset + 1] = (byte) ((value >> 8) & 0xFF); data[offset + 2] = (byte) ((value >> 16) & 0xFF); data[offset + 3] = (byte) ((value >> 24) & 0xFF); } private static void encodeInt32Goal(byte[] data, int offset, int value) { encodeInt32(data, offset, value); data[offset + 3] = 0; } private static byte encodeTimeFormat(TimeFormat tf) { return (byte) ((tf == TimeFormat.Format12H) ? 1 : 0); } public static int decodeInt32(byte[] data, int offset) { return ((data[offset] & 0xFF) << 24) | ((data[offset + 1] & 0xFF) << 16) | ((data[offset + 2] & 0xFF) << 8) | (data[offset + 3] & 0xFF); } private static byte[] byteArrayAdd(byte[] b1, byte[] b2) { if (b2 == null || b2.length == 0) return b1; byte[] result = new byte[b1.length + b2.length]; System.arraycopy(b1, 0, result, 0, b1.length); System.arraycopy(b2, 0, result, b1.length, b2.length); return result; } public static int calculateCrc(byte[] data) { int crc = 0xFF; for (byte b : data) { crc ^= b & 0xFF; for (int k = 0; k < 8; k++) { if ((crc & 1) != 0) { crc = (crc >> 1) ^ 184; } else { crc = (crc >> 1); } } } return crc; } private static final byte HEADER_MAGIC_NUMBER = (byte) 186; private static final byte PROTOCOL_VERSION_0 = (byte) 0; private static byte[] encodeMessage(byte[] commandData) { // msgType | 1: single message or end of multi-part message, 3: one part of a multi-part message byte msgType = 1; byte unknown1 = 0; // 1 bit byte unknown2 = 0; // 4 bits int length = commandData.length; int crc = calculateCrc(commandData); int msgCounter = 0; // returned with the response byte[] header = new byte[8]; header[0] = HEADER_MAGIC_NUMBER; header[1] = (byte) ((msgType << 5) | (unknown1 << 4) | unknown2); header[2] = (byte) ((length >> 8) & 0xFF); header[3] = (byte) (length & 0xFF); header[4] = (byte) ((crc >> 8) & 0xFF); header[5] = (byte) (crc & 0xFF); header[6] = (byte) ((msgCounter >> 8) & 0xFF); header[7] = (byte) (msgCounter & 0xFF); return byteArrayAdd(header, commandData); } private static byte[] encodeCommand(byte cmd, byte cmdKey, byte[] argsData) { int length = (argsData != null) ? argsData.length : 0; byte[] header = new byte[5]; header[0] = cmd; header[1] = PROTOCOL_VERSION_0; header[2] = cmdKey; header[3] = (byte) ((length >> 8) & 1); header[4] = (byte) (length & 0xFF); return byteArrayAdd(header, argsData); } public static byte[] encodeCommandMessage(int cmd, int cmdKey, byte[] cmdArgsData) { assert (cmd > 0); assert (cmd <= 0xFF); assert (cmdKey > 0); assert (cmdKey <= 0xFF); return encodeMessage(encodeCommand((byte) cmd, (byte) cmdKey, cmdArgsData)); } public static class CommandMessage { // decoded message public byte msgType; public int cmd; public int key; public byte[] cmdArgs; } public static CommandMessage decodeCommandMessage(byte[] data) { final int MESSAGE_HEADER_SIZE = 8; final int COMMAND_HEADER_SIZE = 5; if (data[0] != HEADER_MAGIC_NUMBER) { return null; } byte messageType = (byte) (data[1] >> 5); int messageLength = ((data[2] & 0xFF) << 8) | (data[3] & 0xFF); int crcReceived = ((data[4] & 0xFF) << 8) | (data[5] & 0xFF); //int msgCounter = ((data[6] & 0xFF) << 8) | (data[7] & 0xFF); if (data.length != MESSAGE_HEADER_SIZE + messageLength) { return null; } { byte[] commandData = new byte[messageLength]; System.arraycopy(data, MESSAGE_HEADER_SIZE, commandData, 0, messageLength); int crc = calculateCrc(commandData); if (crc != crcReceived) { return null; } } int cmd = data[MESSAGE_HEADER_SIZE + 0] & 0xFF; int version = data[MESSAGE_HEADER_SIZE + 1] & 0xFF; int key = data[MESSAGE_HEADER_SIZE + 2] & 0xFF; int commandLength = ((data[MESSAGE_HEADER_SIZE + 3] & 1) << 8) | (data[MESSAGE_HEADER_SIZE + 4] & 0xFF); if (version != PROTOCOL_VERSION_0) { return null; } if (data.length != MESSAGE_HEADER_SIZE + COMMAND_HEADER_SIZE + commandLength) { return null; } byte[] commandArgs = new byte[commandLength]; System.arraycopy(data, MESSAGE_HEADER_SIZE + COMMAND_HEADER_SIZE, commandArgs, 0, commandLength); CommandMessage result = new CommandMessage(); result.msgType = messageType; result.cmd = cmd; result.key = key; result.cmdArgs = commandArgs; return result; } public static byte[] encodeGetFirmwareVersion() { return encodeCommandMessage(1, 18, null); } public static byte[] encodeGetBatteryStatus() { return encodeCommandMessage(4, 64, null); } private static void encodeDateTime(byte[] data, int dataOffset, int year, int month, int day, int hour, int minute, int second) { data[dataOffset + 0] = (byte) ((year % 100) & 0xFF); data[dataOffset + 1] = (byte) (month & 0xFF); data[dataOffset + 2] = (byte) (day & 0xFF); data[dataOffset + 3] = (byte) (hour & 0xFF); data[dataOffset + 4] = (byte) (minute & 0xFF); data[dataOffset + 5] = (byte) (second & 0xFF); } public enum TimeFormat { Format12H, Format24H } public static byte[] encodeSetDateTime(Calendar dt, TimeFormat timeFormat) { byte[] args = new byte[7]; encodeDateTime(args, 0, dt.get(Calendar.YEAR), dt.get(Calendar.MONTH) + 1, dt.get(Calendar.DAY_OF_MONTH), dt.get(Calendar.HOUR_OF_DAY), dt.get(Calendar.MINUTE), dt.get(Calendar.SECOND)); // time format value is optional. shorter message is also accepted args[6] = encodeTimeFormat(timeFormat); return encodeCommandMessage(2, 32, args); } public enum Gender { FEMALE, MALE } public static byte[] encodeSetUserInfo(Gender gender, int age, int heightCm, int weightKg, int stepsGoal) { byte[] args = new byte[8]; args[0] = (byte) ((gender == Gender.MALE) ? 1 : 0); args[1] = (byte) (age & 0xFF); args[2] = (byte) (heightCm & 0xFF); args[3] = (byte) (weightKg & 0xFF); encodeInt32Goal(args, 4, stepsGoal); return encodeCommandMessage(2, 35, args); } public static byte[] encodeFindDevice() { return encodeCommandMessage(5, 80, null); } public enum NotificationType { INCOMING_CALL(0), // shows popup with hang up button SMS(1), MQQ(2), WEIXIN(3), FACEBOOK(4), TWITTER(6), WHATSAPP(7), INSTAGRAM(8), LINKEDIN(9), CALL_REJECT(15), // closes INCOMING_CALL notification CALL_ACCEPT(16), // closes INCOMING call notification UNKNOWN(255); private final byte value; NotificationType(final int value) { this.value = (byte) value; } public byte getValue() { return this.value; } } public static byte[] encodeShowNotification(NotificationType type, String text) { final int TEXT_LENGTH_MAX = 64; text = text.trim(); if (text.length() > TEXT_LENGTH_MAX) { text = StringUtils.truncate(text, TEXT_LENGTH_MAX); text = text.trim(); // trim again so text is centered on screen } byte[] args = byteArrayAdd(new byte[]{type.getValue()}, text.getBytes(StandardCharsets.UTF_16LE)); return encodeCommandMessage(6, 96, args); } public static byte[] encodeSetAlarms(ArrayList alarms) { final int ENTRY_COUNT = 5; final int ENTRY_SIZE = 5; byte[] args = new byte[ENTRY_COUNT * ENTRY_SIZE]; Arrays.fill(args, (byte) 0); int offset = 0; for (Alarm alarm : alarms) { // When all properties of an alarm are 0, it will not be listed in the watch UI. // Disabled alarms with non-0 properties will be shown in disabled state. // Show only enabled: if (alarm.getEnabled() && !alarm.getUnused()) { args[offset + 0] = (byte) alarm.getHour(); args[offset + 1] = (byte) alarm.getMinute(); args[offset + 2] = (byte) alarm.getRepetition(); args[offset + 3] = (byte) 1; args[offset + 4] = encodeBoolean(alarm.getEnabled()); offset += ENTRY_SIZE; } } return encodeCommandMessage(2, 33, args); } private static final Map LANGUAGES = new HashMap() {{ put("en", 1); put("es", 3); put("de", 4); put("it", 5); put("fr", 6); put("sv", 18); put("ru", 2); put("pt", 7); put("pl", 8); put("nl", 9); put("el", 10); put("tr", 11); put("ro", 12); put("ja", 13); put("he", 15); put("da", 16); put("sr", 17); put("cs", 19); put("sk", 20); put("hu", 21); put("ar", 22); put("bg", 23); put("th", 24); put("uk", 25); put("fi", 26); put("nb", 27); put("ko", 28); put("id", 29); put("lv", 30); put("lt", 31); put("et", 32); put("my", 33); put("vi", 34); put("hr", 35); }}; private static int resolveLanguageId(String language_, String country_) { String language = language_.toLowerCase(); String country = country_.toLowerCase(); if (language.equals("zh")) { switch (country) { case "cn": return 0; case "tw": case "hk": case "mo": return 14; } } else if (language.equals("pt") && country.equals("br")) { return 36; } else { Integer languageId = LANGUAGES.get(language); if (languageId != null) return languageId; } return LANGUAGES.get("en"); } public static byte[] encodeSetSystemData(String lang, String country, TimeFormat timeFormat) { byte[] args = new byte[4]; args[0] = (byte) resolveLanguageId(lang, country); args[1] = encodeTimeFormat(timeFormat); args[2] = (byte) 60; // screen. unclear args[3] = (byte) 0; // pair. 1 has no effect return encodeCommandMessage(2, 39, args); } public static byte[] encodeSetDoNotDisturb(boolean enable, int startHour, int startMinute, int endHour, int endMinute) { byte[] args = new byte[5]; args[0] = encodeBoolean(enable); args[1] = (byte) startHour; args[2] = (byte) startMinute; args[3] = (byte) endHour; args[4] = (byte) endMinute; return encodeCommandMessage(6, 100, args); } public static byte[] encodeSetUnit(boolean isMetric) { byte[] args = new byte[2]; args[0] = args[1] = (byte) ((isMetric) ? 0 : 1); // 0: metric, 1: imperial return encodeCommandMessage(2, 1, args); } public static byte[] encodeSetLiftWristDisplayOn(boolean enable) { byte[] args = new byte[3]; args[0] = (byte) 1; // hand args[1] = encodeBoolean(enable); // raise args[2] = encodeBoolean(enable); // wrist return encodeCommandMessage(4, 74, args); } public static byte[] encodeSetAutoHeartRate(boolean enable, int startHour, int startMinute, int endHour, int endMinute, int intervalMinutes) { byte[] args = new byte[7]; args[0] = encodeBoolean(enable); args[1] = (byte) startHour; args[2] = (byte) startMinute; args[3] = (byte) endHour; args[4] = (byte) endMinute; encodeInt16(args, 5, intervalMinutes); return encodeCommandMessage(9, 146, args); } public static byte[] encodeSetHydrationReminder(boolean enable, int startHour, int startMinute, int endHour, int endMinute, int intervalMinutes) { byte[] args = new byte[8]; args[0] = encodeBoolean(enable); args[1] = (byte) startHour; args[2] = (byte) startMinute; args[3] = (byte) endHour; args[4] = (byte) endMinute; args[5] = (byte) 0x7F; // repeat: bitmask for days of week, bit 0=Monday encodeInt16(args, 6, intervalMinutes); return encodeCommandMessage(2, 40, args); } public static byte[] encodeFactoryReset() { return encodeCommandMessage(2, 199, null); } }