/* Copyright (C) 2023-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.huami.zeppos.services; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.util.Calendar; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.model.Reminder; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; public class ZeppOsRemindersService extends AbstractZeppOsService { private static final Logger LOG = LoggerFactory.getLogger(ZeppOsRemindersService.class); private static final short ENDPOINT = 0x0038; private static final byte CMD_CAPABILITIES_REQUEST = 0x01; private static final byte CMD_CAPABILITIES_RESPONSE = 0x02; private static final byte CMD_REQUEST = 0x03; private static final byte CMD_RESPONSE = 0x04; private static final byte CMD_CREATE = 0x05; private static final byte CMD_CREATE_ACK = 0x06; private static final byte CMD_UPDATE = 0x07; private static final byte CMD_UPDATE_ACK = 0x08; private static final byte CMD_DELETE = 0x09; private static final byte CMD_DELETE_ACK = 0x0a; private static final int FLAG_ENABLED = 0x0001; private static final int FLAG_TEXT = 0x0008; private static final int FLAG_REPEAT_MONTH = 0x1000; private static final int FLAG_REPEAT_YEAR = 0x2000; private static final String PREF_CAPABILITY = "huami_2021_capability_reminders"; private final Map deviceReminders = new HashMap<>(); public ZeppOsRemindersService(final ZeppOsSupport support) { super(support, false); } @Override public short getEndpoint() { return ENDPOINT; } @Override public void handlePayload(final byte[] payload) { switch (payload[0]) { case CMD_CAPABILITIES_RESPONSE: LOG.info("Reminder capability, version = {}", payload[1]); if (payload[1] != 1) { LOG.warn("Reminder unsupported version {}", payload[1]); return; } final int numReminders = payload[2] & 0xff; final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences( PREF_CAPABILITY, numReminders ); evaluateGBDeviceEvent(eventUpdatePreferences); return; case CMD_CREATE_ACK: LOG.info("Reminder create ACK, status = {}", payload[1]); return; case CMD_DELETE_ACK: LOG.info("Reminder delete ACK, status = {}", payload[1]); // status 1 = success // status 2 = reminder not found return; case CMD_UPDATE_ACK: LOG.info("Reminder update ACK, status = {}", payload[1]); return; case CMD_RESPONSE: LOG.info("Got reminders from band"); decodeAndUpdateReminders(payload); final TransactionBuilder builder = getSupport().createTransactionBuilder("send reminders"); sendReminders(builder); builder.queue(getSupport().getQueue()); return; default: LOG.warn("Unexpected reminders payload byte {}", String.format("0x%02x", payload[0])); } } @Override public void initialize(final TransactionBuilder builder) { requestCapabilities(builder); requestReminders(builder); } private void requestCapabilities(final TransactionBuilder builder) { write(builder, CMD_CAPABILITIES_REQUEST); } private void requestReminders(final TransactionBuilder builder) { write(builder, CMD_REQUEST); } public void sendReminders(final TransactionBuilder builder) { final List reminders = DBHelper.getReminders(getSupport().getDevice()); sendReminders(builder, reminders); } public void sendReminders(final TransactionBuilder builder, final List reminders) { LOG.info("On Set Reminders: {}", reminders.size()); final int reminderSlotCount = getCoordinator().getReminderSlotCount(getSupport().getDevice()); if (reminderSlotCount <= 0) { LOG.warn("Reminders not yet initialized"); return; } final Date currentDate = new Date(); final Set allReminders = new HashSet<>(); final LinkedList toDelete = new LinkedList<>(); final LinkedList toSend = new LinkedList<>(); for (final Reminder reminder : reminders) { if (currentDate.after(reminder.getDate())) { // Disregard reminders from the past, device ignores them anyway (does not even send // them back when requesting). continue; } final ZeppOsReminder newReminder = new ZeppOsReminder(reminder); allReminders.add(newReminder); if (deviceReminders.containsKey(newReminder)) { // Reminder exists and is up-to-date continue; } toSend.push(newReminder); } for (final ZeppOsReminder reminder : deviceReminders.keySet()) { if (!allReminders.contains(reminder)) { toDelete.add(reminder); } } for (final ZeppOsReminder reminder : toSend) { if (!toDelete.isEmpty()) { // If we have reminders to delete, replace them with the ones we want to send final ZeppOsReminder reminderToReplace = toDelete.pop(); final Integer position = deviceReminders.get(reminderToReplace); if (position == null) { LOG.error("Failed to find position for {} - this should never happen", reminderToReplace); // We somehow got out of sync - request all reminders again requestReminders(builder); return; } LOG.debug("Updating reminder at position {}", position); sendReminderToDevice(builder, position, true, reminder); deviceReminders.remove(reminderToReplace); deviceReminders.put(reminder, position); } else { // Find the next available position for (int position = 0; position < reminderSlotCount; position++) { if (!deviceReminders.containsValue(position)) { LOG.debug("Creating reminder at position {}", position); sendReminderToDevice(builder, position, false, reminder); deviceReminders.put(reminder, position); break; } } } } for (final ZeppOsReminder reminder : toDelete) { final Integer position = deviceReminders.remove(reminder); if (position != null) { LOG.debug("Deleting reminder at position {}", position); sendReminderToDevice(builder, position, true, null); } } } private void sendReminderToDevice(final TransactionBuilder builder, int position, final boolean update, final ZeppOsReminder reminder) { final DeviceCoordinator coordinator = getCoordinator(); final int reminderSlotCount = coordinator.getReminderSlotCount(getSupport().getDevice()); if (position + 1 > reminderSlotCount) { LOG.error("Reminder for position {} is over the limit of {} reminders", position, reminderSlotCount); return; } if (reminder == null) { // Delete reminder write(builder, new byte[]{CMD_DELETE, (byte) (position & 0xFF)}); return; } final ByteBuffer buf = ByteBuffer.allocate(1 + 10 + reminder.getText().getBytes(StandardCharsets.UTF_8).length + 1); buf.order(ByteOrder.LITTLE_ENDIAN); // Update does an upsert, so let's use it. If we call create twice on the same ID, it becomes weird buf.put(update ? CMD_UPDATE : CMD_CREATE); buf.put((byte) (position & 0xFF)); buf.putInt(reminder.getFlags()); buf.putInt(reminder.getTimestamp()); buf.put((byte) 0x00); buf.put(reminder.getText().getBytes(StandardCharsets.UTF_8)); buf.put((byte) 0x00); write(builder, buf.array()); } private void decodeAndUpdateReminders(final byte[] payload) { final int numReminders = payload[1] & 0xff; if (payload.length < 3 + numReminders * 11) { LOG.warn("Unexpected payload length of {} for {} reminders", payload.length, numReminders); return; } LOG.debug("Got {} reminders from band", numReminders); deviceReminders.clear(); int i = 3; while (i < payload.length) { if (payload.length - i < 11) { LOG.error("Not enough bytes remaining to parse a reminder ({})", payload.length - i); return; } final int reminderPosition = payload[i++] & 0xff; final int reminderFlags = BLETypeConversions.toUint32(payload, i); i += 4; final int reminderTimestamp = BLETypeConversions.toUint32(payload, i); i += 4; i++; // 0 ? final Date reminderDate = new Date(reminderTimestamp * 1000L); final String reminderText = StringUtils.untilNullTerminator(payload, i); if (reminderText == null) { LOG.error("Failed to parse reminder text at pos {}", i); return; } final ZeppOsReminder zeppOsReminder = new ZeppOsReminder( reminderFlags, reminderTimestamp, reminderText ); deviceReminders.put(zeppOsReminder, reminderPosition); i += reminderText.length() + 1; LOG.info("Reminder[{}]: {}, {}, {}", reminderPosition, String.format("0x%04x", reminderFlags), reminderDate, reminderText); } if (i != payload.length) { LOG.error("Unexpected reminders payload trailer, {} bytes were not consumed", payload.length - i); } // TODO persist in database. Probably not trivial, because reminderPosition != reminderId } public static int getSlotCount(final Prefs devicePrefs) { return devicePrefs.getInt(PREF_CAPABILITY, 0); } private class ZeppOsReminder { final int flags; final int timestamp; final String text; private ZeppOsReminder(final int flags, final int timestamp, final String text) { this.flags = flags; this.timestamp = timestamp; this.text = text; } private ZeppOsReminder(final Reminder reminder) { this.flags = getReminderFlags(reminder); this.timestamp = (int) (reminder.getDate().getTime() / 1000L); if (reminder.getMessage().length() > getCoordinator().getMaximumReminderMessageLength()) { LOG.warn("The reminder message length {} is longer than {}, will be truncated", reminder.getMessage().length(), getCoordinator().getMaximumReminderMessageLength() ); text = StringUtils.truncate(reminder.getMessage(), getCoordinator().getMaximumReminderMessageLength()); } else { text = reminder.getMessage(); } } public int getFlags() { return flags; } public int getTimestamp() { return timestamp; } public String getText() { return text; } private int getReminderFlags(final Reminder reminder) { final Calendar cal = BLETypeConversions.createCalendar(); cal.setTime(reminder.getDate()); int reminderFlags = FLAG_ENABLED | FLAG_TEXT; switch (reminder.getRepetition()) { case Reminder.ONCE: // Default is once, nothing to do break; case Reminder.EVERY_DAY: reminderFlags |= 0x0fe0; // all week day bits set break; case Reminder.EVERY_WEEK: int dayOfWeek = BLETypeConversions.dayOfWeekToRawBytes(cal) - 1; // Monday = 0 reminderFlags |= 0x20 << dayOfWeek; break; case Reminder.EVERY_MONTH: reminderFlags |= FLAG_REPEAT_MONTH; break; case Reminder.EVERY_YEAR: reminderFlags |= FLAG_REPEAT_YEAR; break; default: LOG.warn("Unknown repetition for reminder {}, defaulting to once", reminder.getReminderId()); } return reminderFlags; } @Override public boolean equals(final Object o) { if (this == o) return true; if (!(o instanceof ZeppOsReminder)) return false; final ZeppOsReminder that = (ZeppOsReminder) o; return flags == that.flags && timestamp == that.timestamp && Objects.equals(text, that.text); } @Override public int hashCode() { return Objects.hash(flags, timestamp, text); } } }