Zepp OS: Extract world clocks to standalone service

This commit is contained in:
José Rebelo
2025-04-06 13:52:46 +01:00
parent 4f47e2a881
commit 7a869e24e3
3 changed files with 189 additions and 105 deletions
@@ -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<? extends WorldClock> clocks) {
private void sendWorldClocks(final TransactionBuilder builder, final List<? extends WorldClock> 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
@@ -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<Short> 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<? extends WorldClock> clocks) {
worldClocksService.onSetWorldClocks(clocks);
}
@Override
public void onSetLoyaltyCards(final ArrayList<LoyaltyCard> cards) {
loyaltyCardService.setCards(cards);
@@ -467,11 +476,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
contactsService.setContacts((List<Contact>) contacts);
}
@Override
protected boolean isWorldClocksEncrypted() {
return true;
}
@Override
public void onDeleteNotification(final int id) {
notificationService.deleteNotification(id);
@@ -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 <https://www.gnu.org/licenses/>. */
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<? extends WorldClock> 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<? extends WorldClock> 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);
}
}
}