mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
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:
+16
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+51
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
-4
@@ -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 {
|
||||
|
||||
+145
-9
@@ -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;
|
||||
}
|
||||
|
||||
+64
@@ -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.
|
||||
*
|
||||
|
||||
+93
-26
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+68
-18
@@ -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) {
|
||||
|
||||
+13
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user