Pebble: Add support for dual slot firmware

This commit is contained in:
Benjamin Temple
2026-05-17 08:06:10 -07:00
parent f920968deb
commit c29064a36c
5 changed files with 140 additions and 10 deletions
@@ -30,10 +30,23 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
public class GBDeviceEventVersionInfo extends GBDeviceEvent {
private static final Logger LOG = LoggerFactory.getLogger(GBDeviceEventVersionInfo.class);
/**
* Extra info key for dual-slot firmware target slot.
* Value is Integer: 0 for slot 0, 1 for slot 1.
*/
public static final String EXTRA_FW_UPDATE_TARGET_SLOT = "pebble_fw_update_target_slot";
public String fwVersion = "N/A";
public String fwVersion2 = null;
public String hwVersion = "N/A";
/**
* For dual-slot firmware devices (like Pebble Time 2), this indicates which slot
* firmware updates should target. null means not a dual-slot device.
* 0 = update slot 0, 1 = update slot 1
*/
public Integer fwUpdateTargetSlot = null;
public GBDeviceEventVersionInfo() {
if (GBApplication.getContext() != null) {
// Only get from context if there is one (eg. not in unit tests)
@@ -63,6 +76,9 @@ public class GBDeviceEventVersionInfo extends GBDeviceEvent {
if (hwVersion != null) {
device.setModel(hwVersion);
}
if (fwUpdateTargetSlot != null) {
device.setExtraInfo(EXTRA_FW_UPDATE_TARGET_SLOT, fwUpdateTargetSlot);
}
device.sendDeviceUpdateIntent(context);
}
}
@@ -48,6 +48,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleHardware;
import nodomain.freeyourgadget.gadgetbridge.util.PebbleUtils;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
public class PBWInstallHandler implements InstallHandler {
private static final Logger LOG = LoggerFactory.getLogger(PBWInstallHandler.class);
@@ -75,9 +76,10 @@ public class PBWInstallHandler implements InstallHandler {
}
String platformName = PebbleHardware.getPlatformName(device.getModel());
Integer targetSlot = (Integer) device.getExtraInfo(GBDeviceEventVersionInfo.EXTRA_FW_UPDATE_TARGET_SLOT);
try {
mPBWReader = new PBWReader(mUri, mContext, platformName);
mPBWReader = new PBWReader(mUri, mContext, platformName, targetSlot);
} catch (FileNotFoundException e) {
installActivity.setInfoText("file not found");
installActivity.setInstallEnabled(false);
@@ -75,7 +75,18 @@ public class PBWReader {
private JSONObject mAppKeys = null;
/**
* For dual-slot firmware devices, the slot to install to.
* null for non-dual-slot devices, 0 or 1 for dual-slot devices.
*/
private final Integer targetSlot;
public PBWReader(Uri uri, Context context, String platform) throws IOException {
this(uri, context, platform, null);
}
public PBWReader(Uri uri, Context context, String platform, Integer targetSlot) throws IOException {
this.targetSlot = targetSlot;
uriHelper = UriHelper.get(uri, context);
if (uriHelper.getFileName().endsWith(".pbl")) {
@@ -99,7 +110,11 @@ public class PBWReader {
}
String platformDir = "";
if (!uriHelper.getFileName().endsWith(".pbz")) {
boolean isPbzFirmware = uriHelper.getFileName().endsWith(".pbz");
int firmwareSlotCount = 0;
String firstSlotHwRevision = null;
if (!isPbzFirmware) {
platformDir = determinePlatformDir(uriHelper, platform);
if (platform.equals("chalk") && platformDir.equals("")) {
@@ -125,7 +140,35 @@ public class PBWReader {
try (ZipInputStream zis = new ZipInputStream(uriHelper.openInputStream())) {
while ((ze = zis.getNextEntry()) != null) {
String fileName = ze.getName();
if (fileName.equals(platformDir + "manifest.json")) {
// For .pbz firmware files, check for slot-based manifests (slot0/, slot1/)
// as well as root manifest.json
String slotPrefix = "";
boolean isManifest = fileName.equals(platformDir + "manifest.json");
if (isPbzFirmware && !isManifest) {
// Check for dual-slot firmware format (e.g., slot0/manifest.json, slot1/manifest.json)
if (fileName.matches("slot\\d+/manifest\\.json")) {
slotPrefix = fileName.substring(0, fileName.indexOf('/') + 1);
// If targetSlot is specified, only match that slot's manifest
if (targetSlot != null) {
String expectedPrefix = "slot" + targetSlot + "/";
if (slotPrefix.equals(expectedPrefix)) {
isManifest = true;
LOG.info("Using target slot {} firmware manifest: {}", targetSlot, fileName);
} else {
LOG.info("Skipping slot manifest {} (target is slot {})", fileName, targetSlot);
}
} else {
// No target slot specified, process all slots (legacy behavior)
isManifest = true;
LOG.info("Found slot-based firmware manifest: " + fileName);
}
}
}
if (isManifest) {
String manifestSlotPrefix = slotPrefix.isEmpty() ? platformDir : slotPrefix;
long bytes = ze.getSize();
if (bytes > 8192) // that should be too much
break;
@@ -138,17 +181,47 @@ public class PBWReader {
String jsonString = baos.toString();
try {
JSONObject json = new JSONObject(jsonString);
HashMap<String, Byte> fileTypeMap;
HashMap<String, Byte> fileTypeMap = null;
try {
JSONObject firmware = json.getJSONObject("firmware");
fileTypeMap = fwFileTypesMap;
isFirmware = true;
hwRevision = firmware.getString("hwrev");
String slotHwRevision = firmware.getString("hwrev");
// For dual-slot firmware, validate consistency
if (!slotPrefix.isEmpty()) {
firmwareSlotCount++;
if (firstSlotHwRevision == null) {
firstSlotHwRevision = slotHwRevision;
hwRevision = slotHwRevision;
} else if (!firstSlotHwRevision.equals(slotHwRevision)) {
isValid = false;
invalidReason = "Dual-slot firmware hwrev mismatch: " + firstSlotHwRevision + " vs " + slotHwRevision;
LOG.error(invalidReason);
// Will exit via invalidReason check below
}
LOG.info("Slot {} firmware hwrev: {}", slotPrefix, slotHwRevision);
} else {
hwRevision = slotHwRevision;
}
} catch (JSONException e) {
fileTypeMap = appFileTypesMap;
isFirmware = false;
// For dual-slot .pbz files, all slots must be firmware
if (isPbzFirmware && !slotPrefix.isEmpty()) {
isValid = false;
invalidReason = "Slot " + slotPrefix + " is not firmware (missing firmware object in manifest)";
LOG.error(invalidReason);
} else {
fileTypeMap = appFileTypesMap;
isFirmware = false;
}
}
// Exit early if validation failed
if (invalidReason != null || fileTypeMap == null) {
break;
}
for (Map.Entry<String, Byte> entry : fileTypeMap.entrySet()) {
try {
JSONObject jo = json.getJSONObject(entry.getKey());
@@ -156,8 +229,8 @@ public class PBWReader {
int size = jo.getInt("size");
long crc = jo.getLong("crc");
byte type = entry.getValue();
pebbleInstallables.add(new PebbleInstallable(platformDir + name, size, (int) crc, type));
LOG.info("found file to install: " + platformDir + name);
pebbleInstallables.add(new PebbleInstallable(manifestSlotPrefix + name, size, (int) crc, type));
LOG.info("found file to install: " + manifestSlotPrefix + name);
isValid = true;
} catch (JSONException e) {
// not fatal
@@ -251,6 +324,29 @@ public class PBWReader {
}
}
// Validate dual-slot firmware
if (isValid && isFirmware && firmwareSlotCount > 0) {
if (targetSlot != null) {
// When target slot is specified, we should have exactly 1 slot
if (firmwareSlotCount == 1) {
LOG.info("Target slot {} firmware ready for install, hwrev '{}'", targetSlot, hwRevision);
} else {
// This shouldn't happen if our filtering is correct
LOG.warn("Expected 1 slot for target {}, found {}", targetSlot, firmwareSlotCount);
}
} else {
// Legacy behavior: when no target slot specified, require both slots
if (firmwareSlotCount < 2) {
isValid = false;
invalidReason = "Dual-slot firmware incomplete: found only " + firmwareSlotCount + " slot(s), expected 2. " +
"Device may need to report its running slot for single-slot install.";
LOG.error(invalidReason);
} else {
LOG.info("Dual-slot firmware validated: both slots present with matching hwrev '{}'", hwRevision);
}
}
}
// Set default invalid reason if still not valid and no specific reason
if (!isValid && invalidReason == null) {
if (pebbleInstallables == null || pebbleInstallables.isEmpty()) {
@@ -608,9 +608,10 @@ class PebbleIoThread extends GBDeviceIoThread {
}
String platformName = PebbleHardware.getPlatformName(gbDevice.getModel());
Integer targetSlot = (Integer) gbDevice.getExtraInfo(GBDeviceEventVersionInfo.EXTRA_FW_UPDATE_TARGET_SLOT);
try {
mPBWReader = new PBWReader(uri, getContext(), platformName);
mPBWReader = new PBWReader(uri, getContext(), platformName, targetSlot);
} catch (FileNotFoundException e) {
LOG.warn("app file not found", e);
return;
@@ -293,6 +293,11 @@ public class PebbleProtocol extends GBDeviceProtocol {
int mFwMajor = 3;
boolean isNewEraPebble = false;
// Dual-slot firmware support
// Bit 2 (0x04) = IsDualSlot, Bit 3 (0x08) = IsSlot0
private boolean mIsDualSlot = false;
private boolean mIsSlot0 = false;
boolean mEnablePebbleKit = false;
boolean mAlwaysACKPebbleKit = false;
private byte[] screenshotData = null;
@@ -2423,6 +2428,16 @@ public class PebbleProtocol extends GBDeviceProtocol {
String gitHash = getFixedString(buf, 8);
int fwFlags = buf.get();
LOG.info("git hash: {}, flags: {}", gitHash, fwFlags);
// Extract dual-slot firmware flags
mIsDualSlot = (fwFlags & 0x04) != 0; // Bit 2: IsDualSlot
mIsSlot0 = (fwFlags & 0x08) != 0; // Bit 3: IsSlot0
if (mIsDualSlot) {
int runningSlot = mIsSlot0 ? 0 : 1;
int targetSlot = mIsSlot0 ? 1 : 0;
versionCmd.fwUpdateTargetSlot = targetSlot;
LOG.info("Dual-slot firmware detected: running slot {}, will update slot {}", runningSlot, targetSlot);
}
int hwRev = buf.get() & 0xFF; // Convert to unsigned
String codename = PebbleHardware.getCodenameByHardwareId(hwRev);
if (codename != null) {