Merge pull request 'Pebble: Add support for installing dual slot firmware' (#5985)

Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/5985
This commit is contained in:
José Rebelo
2026-05-19 19:45:40 +02:00
8 changed files with 470 additions and 58 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);
}
}
@@ -0,0 +1,51 @@
/* Copyright (C) 2025 Benjamin Temple
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.deviceevents.pebble;
import android.content.Context;
import androidx.annotation.NonNull;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
/**
* Sent by the Pebble watch in response to a FirmwareUpdateStart system message.
* The watch indicates whether it is ready to receive the firmware transfer.
*/
public class GBDeviceEventFirmwareUpdateStart extends GBDeviceEvent {
public static final byte STATUS_STOPPED = 0;
public static final byte STATUS_STARTED = 1;
public static final byte STATUS_CANCELLED = 2;
public byte status;
public GBDeviceEventFirmwareUpdateStart(byte status) {
this.status = status;
}
@Override
public void evaluate(@NonNull final Context context, @NonNull final GBDevice device) {
// Stub — handled in PebbleIoThread.evaluateGBDeviceEventPebble
}
@NonNull
@Override
public String toString() {
return super.toString() + "status: " + status;
}
}
@@ -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);
@@ -89,7 +91,12 @@ public class PBWInstallHandler implements InstallHandler {
}
if (!mPBWReader.isValid()) {
installActivity.setInfoText("pbw/pbz is broken or incompatible with your Hardware or Firmware.");
String reason = mPBWReader.getInvalidReason();
if (reason != null) {
installActivity.setInfoText("Invalid: " + reason);
} else {
installActivity.setInfoText("pbw/pbz is broken or incompatible with your Hardware or Firmware.");
}
installActivity.setInstallEnabled(false);
return;
}
@@ -101,7 +108,10 @@ public class PBWInstallHandler implements InstallHandler {
installItem.setIcon(R.drawable.ic_firmware);
String hwRevision = mPBWReader.getHWRevision();
if (hwRevision != null && hwRevision.equals(device.getModel())) {
String deviceModel = device.getModel();
// Use platform-aware compatibility check instead of exact string match
if (PebbleHardware.isFirmwareCompatible(hwRevision, deviceModel)) {
installItem.setName(mContext.getString(R.string.pbw_installhandler_pebble_firmware, ""));
installItem.setDetails(mContext.getString(R.string.pbwinstallhandler_correct_hw_revision));
@@ -112,7 +122,13 @@ public class PBWInstallHandler implements InstallHandler {
installItem.setName(mContext.getString(R.string.pbw_installhandler_pebble_firmware, hwRevision));
installItem.setDetails(mContext.getString(R.string.pbwinstallhandler_incorrect_hw_revision));
}
installActivity.setInfoText(mContext.getString(R.string.pbw_install_handler_hw_revision_mismatch));
// Show detailed incompatibility reason
String reason = PebbleHardware.getFirmwareIncompatibilityReason(hwRevision, deviceModel);
if (reason != null) {
installActivity.setInfoText(reason);
} else {
installActivity.setInfoText(mContext.getString(R.string.pbw_install_handler_hw_revision_mismatch));
}
installActivity.setInstallEnabled(false);
}
} else {
@@ -33,6 +33,7 @@ import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.zip.ZipEntry;
@@ -55,7 +56,8 @@ public class PBWReader {
}
static {
fwFileTypesMap = new HashMap<>();
// LinkedHashMap - preserve insertion order. We should send the firmware blob before the resources
fwFileTypesMap = new LinkedHashMap<>();
fwFileTypesMap.put("firmware", PebbleProtocol.PUTBYTES_TYPE_FIRMWARE);
fwFileTypesMap.put("resources", PebbleProtocol.PUTBYTES_TYPE_SYSRESOURCES);
}
@@ -66,6 +68,7 @@ public class PBWReader {
private boolean isFirmware = false;
private boolean isLanguage = false;
private boolean isValid = false;
private String invalidReason = null;
private String hwRevision = null;
private short mSdkVersion;
private short mAppVersion;
@@ -74,7 +77,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")) {
@@ -98,12 +112,21 @@ 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("")) {
invalidReason = "No chalk/ directory found - app doesn't support Pebble Time Round";
return;
}
if (platformDir.equals("")) {
LOG.info("No platform-specific directory found for {}, will try root", platform);
}
}
LOG.info("using platformdir: '" + platformDir + "'");
@@ -119,7 +142,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;
@@ -132,17 +183,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());
@@ -150,8 +231,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
@@ -160,6 +241,7 @@ public class PBWReader {
} catch (JSONException e) {
// no JSON at all that is a problem
isValid = false;
invalidReason = "Failed to parse manifest.json: " + e.getMessage();
LOG.warn("exception 1 in constructor", e);
break;
}
@@ -190,6 +272,7 @@ public class PBWReader {
}
} catch (JSONException e) {
isValid = false;
invalidReason = "Failed to parse appinfo.json: " + e.getMessage();
LOG.warn("exception 2 in constructor", e);
break;
}
@@ -227,6 +310,51 @@ public class PBWReader {
}
else if (!isFirmware) {
isValid = false;
StringBuilder missing = new StringBuilder();
if (appUUID == null) missing.append("uuid, ");
if (appName == null) missing.append("shortName, ");
if (appCreator == null) missing.append("companyName, ");
if (appVersion == null) missing.append("versionLabel, ");
if (missing.length() > 0) {
missing.setLength(missing.length() - 2); // Remove trailing ", "
invalidReason = "Missing required fields in appinfo.json: " + missing;
} else if (pebbleInstallables.isEmpty()) {
invalidReason = "No installable files found in manifest.json for platform";
} else {
invalidReason = "Unknown app parsing error";
}
}
}
// 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()) {
invalidReason = "No manifest.json found or no installable files for platform: " + platform;
} else {
invalidReason = "Unknown validation error";
}
}
}
@@ -281,6 +409,14 @@ public class PBWReader {
return isValid;
}
/**
* Get the reason why this pbw/pbz is invalid.
* @return Human-readable reason string, or null if valid
*/
public String getInvalidReason() {
return invalidReason;
}
public GBDeviceApp getGBDeviceApp() {
return app;
}
@@ -555,6 +555,70 @@ public class PebbleHardware {
return hw.getPlatform().hasHealth();
}
/**
* Check if a firmware's hwRevision is compatible with a device's model.
* Firmware uses platform names (emery), device uses codenames (obelix_pvt).
*
* @param firmwareHwRev The hwrev from firmware manifest (e.g., "emery")
* @param deviceModel The model from GBDevice (e.g., "obelix_pvt")
* @return true if compatible, false otherwise
*/
public static boolean isFirmwareCompatible(@Nullable String firmwareHwRev, @Nullable String deviceModel) {
if (firmwareHwRev == null || deviceModel == null) {
return false;
}
// Get platforms for both
HardwareRevision fwHw = getByModelString(firmwareHwRev);
HardwareRevision deviceHw = getByModelString(deviceModel);
if (fwHw == null || deviceHw == null) {
LOG.warn("isFirmwareCompatible: Could not resolve platform - fw={} ({}), device={} ({})",
firmwareHwRev, fwHw, deviceModel, deviceHw);
return false;
}
boolean compatible = fwHw.getPlatform() == deviceHw.getPlatform();
LOG.info("isFirmwareCompatible: fw={} ({}), device={} ({}) -> {}",
firmwareHwRev, fwHw.getPlatformName(), deviceModel, deviceHw.getPlatformName(), compatible);
return compatible;
}
/**
* Get a detailed incompatibility reason for firmware/device mismatch.
*
* @param firmwareHwRev The hwrev from firmware manifest
* @param deviceModel The model from GBDevice
* @return Human-readable reason string, or null if compatible
*/
@Nullable
public static String getFirmwareIncompatibilityReason(@Nullable String firmwareHwRev, @Nullable String deviceModel) {
if (firmwareHwRev == null) {
return "Firmware has no hardware revision specified";
}
if (deviceModel == null) {
return "Device model is unknown";
}
HardwareRevision fwHw = getByModelString(firmwareHwRev);
HardwareRevision deviceHw = getByModelString(deviceModel);
if (fwHw == null) {
return "Unknown firmware platform: " + firmwareHwRev;
}
if (deviceHw == null) {
return "Unknown device model: " + deviceModel;
}
if (fwHw.getPlatform() != deviceHw.getPlatform()) {
return String.format("Platform mismatch: firmware is for %s (%s), device is %s (%s)",
fwHw.getPlatform().getDisplayName(), firmwareHwRev,
deviceHw.getPlatform().getDisplayName(), deviceModel);
}
return null; // Compatible
}
/**
* Check if manufacturer data indicates any Pebble device.
*
@@ -42,6 +42,7 @@ import java.net.InetAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
@@ -54,6 +55,7 @@ import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventAppManagem
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventAppMessage;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.pebble.GBDeviceEventDataLogging;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.pebble.GBDeviceEventFirmwareUpdateStart;
import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PBWReader;
import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleInstallable;
@@ -93,6 +95,13 @@ class PebbleIoThread extends GBDeviceIoThread {
private boolean mQuit = false;
private boolean mIsConnected = false;
private boolean mIsInstalling = false;
// Tokens for all committed firmware files; INSTALL is sent for all of them together at the end
// so the watch doesn't start flashing mid-transfer while we're still uploading resources.
private final ArrayList<Integer> mFirmwareCommittedTokens = new ArrayList<>();
// Status from FirmwareUpdateStartResponse (0x0a): -1=pending, 0=stopped, 1=started, 2=cancelled.
private int mFirmwareStartStatus = -1;
// Timestamp (ms) when WAIT_FIRMWARE_START was entered, for recovery-mode timeout.
private long mFirmwareStartTimestamp = 0;
private PBWReader mPBWReader = null;
private GBDeviceApp mCurrentlyInstallingApp = null;
@@ -261,6 +270,25 @@ class PebbleIoThread extends GBDeviceIoThread {
try {
if (mIsInstalling) {
switch (mInstallState) {
case WAIT_FIRMWARE_START:
if (mFirmwareStartStatus == GBDeviceEventFirmwareUpdateStart.STATUS_STARTED) {
LOG.info("Watch confirmed firmware update started");
mFirmwareStartStatus = -1;
mInstallState = PebbleAppInstallState.START_INSTALL;
continue;
} else if (mFirmwareStartStatus == GBDeviceEventFirmwareUpdateStart.STATUS_STOPPED ||
mFirmwareStartStatus == GBDeviceEventFirmwareUpdateStart.STATUS_CANCELLED) {
LOG.warn("Watch rejected firmware update start, status={}", mFirmwareStartStatus);
finishInstall(true);
break;
} else if (System.currentTimeMillis() - mFirmwareStartTimestamp > 5000) {
// No response after 5s — assume recovery mode which never replies.
LOG.info("No FirmwareUpdateStartResponse after 5s, proceeding (recovery mode?)");
mFirmwareStartStatus = -1;
mInstallState = PebbleAppInstallState.START_INSTALL;
continue;
}
break;
case WAIT_SLOT:
if (mInstallSlot == -1) {
finishInstall(true); // no slots available
@@ -320,13 +348,38 @@ class PebbleIoThread extends GBDeviceIoThread {
}
break;
case UPLOAD_COMPLETE:
writeInstallApp(mPebbleProtocol.encodeUploadComplete(mAppInstallToken));
if (++mCurrentInstallableIndex < mPebbleInstallables.length) {
mInstallState = PebbleAppInstallState.START_INSTALL;
if (mPBWReader.isFirmware()) {
// For firmware, defer INSTALL until all files are committed.
// Sending INSTALL early causes the watch to start writing to flash
// immediately, stalling mid-transfer while it's busy erasing. The
// stock app sends INSTALL for all files only after every file is committed.
mFirmwareCommittedTokens.add(mAppInstallToken);
if (++mCurrentInstallableIndex < mPebbleInstallables.length) {
mInstallState = PebbleAppInstallState.START_INSTALL;
} else {
mInstallState = PebbleAppInstallState.INSTALL_FIRMWARE;
}
// continue so the state machine advances immediately
continue;
} else {
mInstallState = PebbleAppInstallState.APP_REFRESH;
// For apps, INSTALL each file immediately after commit.
writeInstallApp(mPebbleProtocol.encodeUploadComplete(mAppInstallToken));
if (++mCurrentInstallableIndex < mPebbleInstallables.length) {
mInstallState = PebbleAppInstallState.START_INSTALL;
} else {
mInstallState = PebbleAppInstallState.APP_REFRESH;
}
}
break;
case INSTALL_FIRMWARE:
// All firmware files are committed; now send INSTALL for each so the
// watch can write them all to flash in one go.
for (int token : mFirmwareCommittedTokens) {
writeInstallApp(mPebbleProtocol.encodeUploadComplete(token));
}
mFirmwareCommittedTokens.clear();
mInstallState = PebbleAppInstallState.APP_REFRESH;
break;
case APP_REFRESH:
if (mPBWReader.isFirmware()) {
writeInstallApp(mPebbleProtocol.encodeInstallFirmwareComplete());
@@ -380,11 +433,6 @@ class PebbleIoThread extends GBDeviceIoThread {
}
}
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
LOG.warn("exception 1 in run", e);
}
} catch (IOException e) {
if (e.getMessage() != null && (e.getMessage().equals("broken pipe") || e.getMessage().contains("socket closed"))) { //FIXME: this does not feel right
LOG.info(e.getMessage());
@@ -428,7 +476,7 @@ class PebbleIoThread extends GBDeviceIoThread {
}
private void write_real(byte[] bytes) {
private void write_stream(byte[] bytes) {
try {
if (mIsTCP) {
ByteBuffer buf = ByteBuffer.allocate(bytes.length + 8);
@@ -447,6 +495,10 @@ class PebbleIoThread extends GBDeviceIoThread {
} catch (IOException e) {
LOG.error("Error writing.", e);
}
}
private void write_real(byte[] bytes) {
write_stream(bytes);
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {
@@ -468,7 +520,11 @@ class PebbleIoThread extends GBDeviceIoThread {
// FIXME: parts are supposed to be generic code
private boolean evaluateGBDeviceEventPebble(GBDeviceEvent deviceEvent) {
if (deviceEvent instanceof GBDeviceEventVersionInfo) {
if (deviceEvent instanceof GBDeviceEventFirmwareUpdateStart) {
mFirmwareStartStatus = ((GBDeviceEventFirmwareUpdateStart) deviceEvent).status;
LOG.info("Got FirmwareUpdateStartResponse: status={}", mFirmwareStartStatus);
return true;
} else if (deviceEvent instanceof GBDeviceEventVersionInfo) {
if (prefs.syncTime()) {
LOG.info("syncing time");
write(mPebbleProtocol.encodeSetTime());
@@ -599,7 +655,10 @@ class PebbleIoThread extends GBDeviceIoThread {
return;
}
LOG.info("got {}bytes for writeInstallApp()", bytes.length);
write_real(bytes);
// Bypass the 100ms post-write sleep in write_real — that sleep prevents appmessage
// NACKs on firmware 3.0+ and is not relevant here. IoThread's blocking read is woken
// immediately when the watch responds, via notifyAll() in writeToPipedOutputStream.
write_stream(bytes);
}
void installApp(Uri uri, int appId) {
@@ -608,9 +667,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;
@@ -625,19 +685,21 @@ class PebbleIoThread extends GBDeviceIoThread {
if (mPBWReader.isFirmware()) {
LOG.info("starting firmware installation");
mIsInstalling = true;
mInstallSlot = 0;
writeInstallApp(mPebbleProtocol.encodeInstallFirmwareStart());
mInstallState = PebbleAppInstallState.START_INSTALL;
/*
* This is a hack for recovery mode, in which the blocking read has no timeout and the
* firmware installation command does not return any ack.
* In normal mode we would got at least out of the blocking read call after a while.
*
*
* ... we should really not handle installation from thread that does the blocking read
*
*/
// For dual-slot watches, mInstallSlot is the PUTBYTES bank number (0 or 1).
// It must match the inactive slot so we don't write into the running firmware.
mInstallSlot = (targetSlot != null) ? targetSlot : 0;
int totalFirmwareBytes = 0;
for (PebbleInstallable pi : mPebbleInstallables) {
totalFirmwareBytes += pi.getFileSize();
}
writeInstallApp(mPebbleProtocol.encodeInstallFirmwareStart(totalFirmwareBytes));
mFirmwareStartStatus = -1;
mFirmwareStartTimestamp = System.currentTimeMillis();
mInstallState = PebbleAppInstallState.WAIT_FIRMWARE_START;
// GetTime is sent to ensure the blocking read unblocks in recovery mode, where the
// watch never sends FirmwareUpdateStartResponse. In normal mode the watch's response
// will arrive first and set mFirmwareStartStatus; the GetTime response is then handled
// on the next loop iteration.
writeInstallApp(mPebbleProtocol.encodeGetTime());
} else {
mCurrentlyInstallingApp = mPBWReader.getGBDeviceApp();
@@ -712,6 +774,9 @@ class PebbleIoThread extends GBDeviceIoThread {
mPBWReader = null;
mIsInstalling = false;
mCurrentlyInstallingApp = null;
mFirmwareCommittedTokens.clear();
mFirmwareStartStatus = -1;
mFirmwareStartTimestamp = 0;
if (mFis != null) {
try {
@@ -780,6 +845,7 @@ class PebbleIoThread extends GBDeviceIoThread {
private enum PebbleAppInstallState {
UNKNOWN,
WAIT_FIRMWARE_START, // waiting for FirmwareUpdateStartResponse from watch (or 5s timeout for recovery mode)
WAIT_SLOT,
START_INSTALL,
WAIT_TOKEN,
@@ -787,6 +853,7 @@ class PebbleIoThread extends GBDeviceIoThread {
UPLOAD_COMMIT,
WAIT_COMMIT,
UPLOAD_COMPLETE,
INSTALL_FIRMWARE, // send INSTALL for all committed firmware tokens after all files are transferred
APP_REFRESH,
}
}
@@ -45,6 +45,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleHardware;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventAppInfo;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventAppManagement;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.pebble.GBDeviceEventFirmwareUpdateStart;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventAppMessage;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl;
@@ -234,6 +235,7 @@ public class PebbleProtocol extends GBDeviceProtocol {
private static final byte SYSTEMMESSAGE_FIRMWARE_OUTOFDATE = 5;
private static final byte SYSTEMMESSAGE_STOPRECONNECTING = 6;
private static final byte SYSTEMMESSAGE_STARTRECONNECTING = 7;
private static final byte SYSTEMMESSAGE_FIRMWARESTART_RESPONSE = 0x0a;
private static final byte PHONEVERSION_REQUEST = 0;
private static final byte PHONEVERSION_APPVERSION_MAGIC = 2; // increase this if pebble complains
@@ -293,6 +295,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;
@@ -1533,16 +1540,28 @@ public class PebbleProtocol extends GBDeviceProtocol {
/* pebble specific install methods */
byte[] encodeUploadStart(byte type, int app_id, int size, String filename) {
// The watch uses two different INIT packet formats:
// - Firmware/recovery/sysresources: type without bit 7, 1-byte bank number
// - App binary/resources/worker: type with bit 7 set, 4-byte app slot
// - File (language): type as-is, 1-byte slot, optional filename
boolean isFirmwareType = (type == PUTBYTES_TYPE_FIRMWARE ||
type == PUTBYTES_TYPE_RECOVERY ||
type == PUTBYTES_TYPE_SYSRESOURCES);
boolean isFileType = (type == PUTBYTES_TYPE_FILE);
short length;
if (type != PUTBYTES_TYPE_FILE) {
if (isFileType) {
length = (short) 7;
if (filename != null) {
length += (short) (filename.getBytes().length + 1);
}
} else if (isFirmwareType) {
// 1-byte bank number; type without bit 7
length = (short) 7;
} else {
// App slot: 4-byte; type with bit 7 to select app-init variant
length = (short) 10;
type |= (byte) 0b10000000;
} else {
length = (short) 7;
}
if (type == PUTBYTES_TYPE_FILE && filename != null) {
length += (short) ((short) filename.getBytes().length + 1);
}
ByteBuffer buf = ByteBuffer.allocate(LENGTH_PREFIX + length);
@@ -1553,16 +1572,16 @@ public class PebbleProtocol extends GBDeviceProtocol {
buf.putInt(size);
buf.put(type);
if (type != PUTBYTES_TYPE_FILE) {
buf.putInt(app_id);
} else {
// slot
if (isFileType) {
buf.put((byte) app_id);
}
if (type == PUTBYTES_TYPE_FILE && filename != null) {
buf.put(filename.getBytes());
buf.put((byte) 0);
if (filename != null) {
buf.put(filename.getBytes());
buf.put((byte) 0);
}
} else if (isFirmwareType) {
buf.put((byte) app_id); // 1-byte bank number
} else {
buf.putInt(app_id); // 4-byte app slot
}
return buf.array();
@@ -1627,8 +1646,20 @@ public class PebbleProtocol extends GBDeviceProtocol {
}
byte[] encodeInstallFirmwareStart() {
return encodeSystemMessage(SYSTEMMESSAGE_FIRMWARESTART);
byte[] encodeInstallFirmwareStart(int totalBytes) {
// FirmwareUpdateStart includes bytesAlreadyTransferred + bytesToSend
// so the watch knows how much data is coming and can pre-erase the full flash region.
final short LENGTH_FIRMWARESTART = 10; // command(1) + messageType(1) + alreadyTransferred(4LE) + bytesToSend(4LE)
ByteBuffer buf = ByteBuffer.allocate(LENGTH_PREFIX + LENGTH_FIRMWARESTART);
buf.order(ByteOrder.BIG_ENDIAN);
buf.putShort(LENGTH_FIRMWARESTART);
buf.putShort(ENDPOINT_SYSTEMMESSAGE);
buf.put((byte) 0); // command
buf.put(SYSTEMMESSAGE_FIRMWARESTART);
buf.order(ByteOrder.LITTLE_ENDIAN); // size fields are little-endian per Pebble protocol
buf.putInt(0); // bytesAlreadyTransferred: always 0 (fresh install)
buf.putInt(totalBytes);
return buf.array();
}
byte[] encodeInstallFirmwareComplete() {
@@ -2132,6 +2163,14 @@ public class PebbleProtocol extends GBDeviceProtocol {
case SYSTEMMESSAGE_STARTRECONNECTING:
LOG.info(ENDPOINT_NAME + ": start reconnecting");
break;
case SYSTEMMESSAGE_FIRMWARESTART_RESPONSE:
if (buf.remaining() >= 1) {
byte status = buf.get();
LOG.info(ENDPOINT_NAME + ": firmware update start response, status={}", status);
return new GBDeviceEventFirmwareUpdateStart(status);
}
LOG.warn(ENDPOINT_NAME + ": firmware update start response missing status byte");
break;
default:
LOG.info(ENDPOINT_NAME + ": {}", command);
break;
@@ -2423,6 +2462,17 @@ 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;
versionCmd.fwVersion2 = "slot " + runningSlot + " active";
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) {
@@ -45,6 +45,10 @@ public class PebbleLESupport {
private PebbleGATTClient mPebbleGATTClient;
private final PipedInputStream mPipedInputStream;
private final PipedOutputStream mPipedOutputStream;
// IoThread's PipedInputStream (its mInStream). We hold this reference so that
// writeToPipedOutputStream can call notifyAll() on the correct monitor to wake
// IoThread's blocking read immediately to receive the ACK for a write.
private final PipedInputStream mIoThreadInputStream;
private int mMTU = 20;
private int mMTULimit;
public boolean clientOnly; // currently experimental, and only possible for Pebble 2
@@ -58,6 +62,7 @@ public class PebbleLESupport {
mBtDevice = btDevice;
mPipedInputStream = new PipedInputStream();
mPipedOutputStream = new PipedOutputStream();
mIoThreadInputStream = pipedInputStream;
try {
pipedOutputStream.connect(mPipedInputStream);
pipedInputStream.connect(mPipedOutputStream);
@@ -100,6 +105,12 @@ public class PebbleLESupport {
private void writeToPipedOutputStream(byte[] value, int count) {
try {
mPipedOutputStream.write(value, 1, count);
// Wake IoThread's blocking read() immediately to receive the ACK after
// finishing a write. Otherwise it just polls every 1000ms which is a
// significant slow-down for large data transfers (firmware).
synchronized (mIoThreadInputStream) {
mIoThreadInputStream.notifyAll();
}
} catch (IOException e) {
LOG.warn("error writing to output stream", e);
}
@@ -245,7 +256,8 @@ public class PebbleLESupport {
while (payloadToSend > 0) {
int chunkSize = Math.min(payloadToSend, maxChunkSize);
byte[] outBuf = new byte[chunkSize + 1];
outBuf[0] = (byte) ((mmSequence++ << 3) & 0xff);
outBuf[0] = (byte) ((mmSequence << 3) & 0xff);
mmSequence = (mmSequence + 1) & 0x1f; // keep serial in 5-bit range (0-31)
System.arraycopy(buf, srcPos, outBuf, 1, chunkSize);
sendDataToPebble(outBuf);
srcPos += chunkSize;