diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java index 5f30008c68..6153807427 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java @@ -143,6 +143,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fet import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchStressManualOperation; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchDebugLogsOperation; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsCannedMessagesService; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWorldClocksService; import nodomain.freeyourgadget.gadgetbridge.util.MediaManager; import nodomain.freeyourgadget.gadgetbridge.util.SilentMode; import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarEvent; @@ -1166,111 +1167,18 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements sendWorldClocks(builder, clocks); } - protected void sendWorldClocks(final TransactionBuilder builder, final List clocks) { + private void sendWorldClocks(final TransactionBuilder builder, final List clocks) { final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator(); if (coordinator.getWorldClocksSlotCount() == 0) { return; } - final ByteArrayOutputStream baos = new ByteArrayOutputStream(); - - try { - baos.write(0x03); - - if (clocks.size() != 0) { - baos.write(clocks.size()); - int i = 0; - for (final WorldClock clock : clocks) { - baos.write(i++); - baos.write(encodeWorldClock(clock)); - } - } else { - baos.write(0); - } - } catch (final IOException e) { - LOG.error("Unable to send world clocks to device", e); - return; - } - - writeToChunked2021(builder, (short) 0x0008, baos.toByteArray(), isWorldClocksEncrypted()); - } - - protected boolean isWorldClocksEncrypted() { - return false; - } - - private byte[] encodeWorldClock(final WorldClock clock) { - final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator(); - - try { - final ByteArrayOutputStream baos = new ByteArrayOutputStream(); - - final TimeZone timezone = TimeZone.getTimeZone(clock.getTimeZoneId()); - final ZoneId zoneId = ZoneId.of(clock.getTimeZoneId()); - - // Usually the 3-letter city code (eg. LIS for Lisbon) - if (clock.getCode() != null) { - baos.write(StringUtils.truncate(clock.getCode(), 3).toUpperCase().getBytes(StandardCharsets.UTF_8)); - } else { - baos.write(StringUtils.truncate(clock.getLabel(), 3).toUpperCase().getBytes(StandardCharsets.UTF_8)); - } - baos.write(0x00); - - // Some other string? Seems to be empty - baos.write(0x00); - - // The city name / label that shows up on the band - baos.write(StringUtils.truncate(clock.getLabel(), coordinator.getWorldClocksLabelLength()).getBytes(StandardCharsets.UTF_8)); - baos.write(0x00); - - // The raw offset from UTC, in number of 15-minute blocks - baos.write((int) (timezone.getRawOffset() / (1000L * 60L * 15L))); - - // Daylight savings - final boolean useDaylightTime = timezone.useDaylightTime(); - final boolean inDaylightTime = timezone.inDaylightTime(new Date()); - byte daylightByte = 0; - // The daylight savings offset, either currently (the previous transition) or future (the next transition), in minutes - byte daylightOffsetMinutes = 0; - - final ZoneRules zoneRules = zoneId.getRules(); - if (useDaylightTime) { - final ZoneOffsetTransition transition; - if (inDaylightTime) { - daylightByte = 0x01; - transition = zoneRules.previousTransition(Instant.now()); - } else { - daylightByte = 0x02; - transition = zoneRules.nextTransition(Instant.now()); - } - daylightOffsetMinutes = (byte) transition.getDuration().toMinutes(); - } - - baos.write(daylightByte); - baos.write(daylightOffsetMinutes); - - // The timestamp of the next daylight savings transition, if any - final ZoneOffsetTransition nextTransition = zoneRules.nextTransition(Instant.now()); - long nextTransitionTs = 0; - if (nextTransition != null) { - nextTransitionTs = nextTransition - .getDateTimeBefore() - .atZone(zoneId) - .toEpochSecond(); - } - - for (int i = 0; i < 4; i++) { - baos.write((byte) ((nextTransitionTs >> (i * 8)) & 0xff)); - } - - if (coordinator.supportsDisabledWorldClocks()) { - baos.write((byte) (clock.getEnabled() ? 0x01 : 0x00)); - } - - return baos.toByteArray(); - } catch (final IOException e) { - throw new RuntimeException("This should never happen", e); - } + writeToChunked2021( + builder, + ZeppOsWorldClocksService.ENDPOINT, + ZeppOsWorldClocksService.encodeWorldClocks(clocks, coordinator), + false + ); } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java index 89f8562c10..66130e3afe 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java @@ -75,6 +75,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsMapsInsta import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsMusicInstallHandler; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes; +import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper; import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator; @@ -146,6 +147,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.service import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWatchfaceService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWeatherService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWifiService; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWorldClocksService; import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -197,6 +199,7 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer private final ZeppOsBatteryService batteryService = new ZeppOsBatteryService(this); private final ZeppOsWeatherService weatherService = new ZeppOsWeatherService(this); private final ZeppOsConnectionService connectionService = new ZeppOsConnectionService(this); + private final ZeppOsWorldClocksService worldClocksService = new ZeppOsWorldClocksService(this); private final Set mSupportedServices = new HashSet<>(); // FIXME: We need to keep track of which services are encrypted for now, since not all of them were yet migrated to a service @@ -236,6 +239,7 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer put(userInfoService.getEndpoint(), userInfoService); put(vibrationPatternsService.getEndpoint(), vibrationPatternsService); put(weatherService.getEndpoint(), weatherService); + put(worldClocksService.getEndpoint(), worldClocksService); }}; public ZeppOsSupport() { @@ -456,6 +460,11 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer } } + @Override + public void onSetWorldClocks(ArrayList clocks) { + worldClocksService.onSetWorldClocks(clocks); + } + @Override public void onSetLoyaltyCards(final ArrayList cards) { loyaltyCardService.setCards(cards); @@ -467,11 +476,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer contactsService.setContacts((List) contacts); } - @Override - protected boolean isWorldClocksEncrypted() { - return true; - } - @Override public void onDeleteNotification(final int id) { notificationService.deleteNotification(id); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsWorldClocksService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsWorldClocksService.java new file mode 100644 index 0000000000..0cf3f8d9ac --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsWorldClocksService.java @@ -0,0 +1,172 @@ +/* Copyright (C) 2025 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.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneId; +import java.time.zone.ZoneOffsetTransition; +import java.time.zone.ZoneRules; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.TimeZone; + +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + +public class ZeppOsWorldClocksService extends AbstractZeppOsService { + private static final Logger LOG = LoggerFactory.getLogger(ZeppOsWorldClocksService.class); + + public static final short ENDPOINT = 0x0008; + + public static final byte CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte CMD_CAPABILITIES_RESPONSE = 0x02; + public static final byte CMD_LIST_SET = 0x03; + public static final byte CMD_LIST_SET_ACK = 0x04; + public static final byte CMD_LIST_GET = 0x05; + public static final byte CMD_LIST_RET = 0x06; + + public ZeppOsWorldClocksService(final ZeppOsSupport support) { + super(support, true); + } + + @Override + public short getEndpoint() { + return ENDPOINT; + } + + @Override + public void handlePayload(final byte[] payload) { + //noinspection SwitchStatementWithTooFewBranches + switch (payload[0]) { + case CMD_LIST_SET_ACK: + LOG.info("World clocks list set ack, status = {}", payload[1]); + return; + default: + LOG.warn("Unexpected world clocks byte {}", String.format("0x%02x", payload[0])); + } + } + + public void onSetWorldClocks(final ArrayList clocks) { + final DeviceCoordinator coordinator = getCoordinator(); + if (coordinator.getWorldClocksSlotCount() == 0) { + LOG.warn("Got world clocks, but device does not support them"); + return; + } + + write("send world clocks", encodeWorldClocks(clocks, coordinator)); + } + + public static byte[] encodeWorldClocks(final List clocks, final DeviceCoordinator coordinator) { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try { + baos.write(0x03); + baos.write(clocks.size()); + int i = 0; + for (final WorldClock clock : clocks) { + baos.write(i++); + baos.write(encodeWorldClock(clock, coordinator)); + } + } catch (final IOException e) { + throw new RuntimeException("This should never happen", e); + } + + return baos.toByteArray(); + } + + public static byte[] encodeWorldClock(final WorldClock clock, final DeviceCoordinator coordinator) { + try { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + final TimeZone timezone = TimeZone.getTimeZone(clock.getTimeZoneId()); + final ZoneId zoneId = ZoneId.of(clock.getTimeZoneId()); + + // Usually the 3-letter city code (eg. LIS for Lisbon) + if (clock.getCode() != null) { + baos.write(StringUtils.truncate(clock.getCode(), 3).toUpperCase().getBytes(StandardCharsets.UTF_8)); + } else { + baos.write(StringUtils.truncate(clock.getLabel(), 3).toUpperCase().getBytes(StandardCharsets.UTF_8)); + } + baos.write(0x00); + + // Some other string? Seems to be empty + baos.write(0x00); + + // The city name / label that shows up on the band + baos.write(StringUtils.truncate(clock.getLabel(), coordinator.getWorldClocksLabelLength()).getBytes(StandardCharsets.UTF_8)); + baos.write(0x00); + + // The raw offset from UTC, in number of 15-minute blocks + baos.write((int) (timezone.getRawOffset() / (1000L * 60L * 15L))); + + // Daylight savings + final boolean useDaylightTime = timezone.useDaylightTime(); + final boolean inDaylightTime = timezone.inDaylightTime(new Date()); + byte daylightByte = 0; + // The daylight savings offset, either currently (the previous transition) or future (the next transition), in minutes + byte daylightOffsetMinutes = 0; + + final ZoneRules zoneRules = zoneId.getRules(); + if (useDaylightTime) { + final ZoneOffsetTransition transition; + if (inDaylightTime) { + daylightByte = 0x01; + transition = zoneRules.previousTransition(Instant.now()); + } else { + daylightByte = 0x02; + transition = zoneRules.nextTransition(Instant.now()); + } + daylightOffsetMinutes = (byte) transition.getDuration().toMinutes(); + } + + baos.write(daylightByte); + baos.write(daylightOffsetMinutes); + + // The timestamp of the next daylight savings transition, if any + final ZoneOffsetTransition nextTransition = zoneRules.nextTransition(Instant.now()); + long nextTransitionTs = 0; + if (nextTransition != null) { + nextTransitionTs = nextTransition + .getDateTimeBefore() + .atZone(zoneId) + .toEpochSecond(); + } + + for (int i = 0; i < 4; i++) { + baos.write((byte) ((nextTransitionTs >> (i * 8)) & 0xff)); + } + + if (coordinator.supportsDisabledWorldClocks()) { + baos.write((byte) (clock.getEnabled() ? 0x01 : 0x00)); + } + + return baos.toByteArray(); + } catch (final IOException e) { + throw new RuntimeException("This should never happen", e); + } + } +}