/*
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 extends Alarm> 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);
}
}