From 0c2dad6f89baddfa3b1e1d1c50fbf0d9483f1477 Mon Sep 17 00:00:00 2001 From: Andreas Shimokawa Date: Mon, 30 Jun 2025 12:25:25 +0200 Subject: [PATCH] ATC_TLSR_Paper: Initial support This is for epaper devices with a TLSR8359 chip, running a fork of openepaperlink that enables BLE The firmware that has to be flashed is this one: https://atc1441.github.io/ATC_BLE_OEPL.bin (there seems to be no source for that) It also has to be configured already to match your screen, so you should already see the openepaperlink screen on the device before using Gadgetbridge Works: - getting screen configuration - sending images to epaper displays with black/white or black/white/red/yellow TODO: - allow configuration of screen type and parameters through Gadgetbridge - firmware update --- CHANGELOG.md | 1 + .../atctlsrpaper/ATCTLSRPaperCoordinator.java | 53 ++++ .../ATCTLSRPaperInstallHandler.java | 50 +++ .../gadgetbridge/model/DeviceType.java | 4 +- .../ATCTLSRPaperDeviceSupport.java | 292 ++++++++++++++++++ app/src/main/res/values/strings.xml | 3 + 6 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/atctlsrpaper/ATCTLSRPaperCoordinator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/atctlsrpaper/ATCTLSRPaperInstallHandler.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/atctlsrpaper/ATCTLSRPaperDeviceSupport.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce6577e5b..6f7ee0568b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Initial support for Garmin Descent Mk3 * Initial support for Redmi Watch 5 * Initial support for Yawell rings R10 and R11 +* Initial support for ATC_TLSR_Paper (experimental) * Experimental support for Amazfit Balance 2 * CMF Watch Pro: Firmware update * Fossil/Skagen Hybrids: Add calendar support diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/atctlsrpaper/ATCTLSRPaperCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/atctlsrpaper/ATCTLSRPaperCoordinator.java new file mode 100644 index 0000000000..d45346991f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/atctlsrpaper/ATCTLSRPaperCoordinator.java @@ -0,0 +1,53 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.atctlsrpaper; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import java.util.regex.Pattern; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.atctlsrpaper.ATCTLSRPaperDeviceSupport; + +public class ATCTLSRPaperCoordinator extends AbstractDeviceCoordinator { + @Override + public int getDeviceNameResource() { + return R.string.devicetype_atc_tlsr_paper; + } + + @Override + public int getDefaultIconResource() { + return R.drawable.ic_device_default; + } + + @Override + public String getManufacturer() { + return "atc1441"; + } + + @NonNull + @Override + public Class getDeviceSupportClass(final GBDevice device) { + return ATCTLSRPaperDeviceSupport.class; + } + @Override + protected Pattern getSupportedDeviceName() { + return Pattern.compile("ATC_.*"); + } + + @Override + public int getBondingStyle() { + return BONDING_STYLE_LAZY; + } + + @Override + public InstallHandler findInstallHandler(final Uri uri, final Context context) { + ATCTLSRPaperInstallHandler installHandler = new ATCTLSRPaperInstallHandler(uri, context); + return installHandler.isValid() ? installHandler : null; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/atctlsrpaper/ATCTLSRPaperInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/atctlsrpaper/ATCTLSRPaperInstallHandler.java new file mode 100644 index 0000000000..473cbd1e57 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/atctlsrpaper/ATCTLSRPaperInstallHandler.java @@ -0,0 +1,50 @@ +/* Copyright (C) 2023-2024 Andreas Shimokawa + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ + +package nodomain.freeyourgadget.gadgetbridge.devices.atctlsrpaper; + +import android.content.Context; +import android.net.Uri; + +import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class ATCTLSRPaperInstallHandler implements InstallHandler { + private final Context mContext; + private final Uri mUri; + + public ATCTLSRPaperInstallHandler(Uri uri, Context context) { + mContext = context; + mUri = uri; + } + + @Override + public boolean isValid() { + return true; + } + + @Override + public void validateInstallation(InstallActivity installActivity, GBDevice device) { + installActivity.setInstallEnabled(true); + } + + @Override + public void onStartInstall(GBDevice device) { + + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java index 8a7f929d30..509e52c040 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -31,6 +31,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.UnknownDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.aawireless.AAWirelessCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.asteroidos.AsteroidOSDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.atctlsrpaper.ATCTLSRPaperCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.bandwpseries.BandWPSeriesDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.binary_sensor.coordinator.BinarySensorCoordinator; @@ -719,7 +720,8 @@ public enum DeviceType { MARSTEK_B2500(MarstekB2500DeviceCoordinator.class), GENERIC_HEADPHONES(GenericHeadphonesCoordinator.class), TEST(TestDeviceCoordinator.class), - ULTRAHUMAN_RING_AIR(UltrahumanDeviceCoordinator.class); + ULTRAHUMAN_RING_AIR(UltrahumanDeviceCoordinator.class), + ATC_TLSR_PAPER(ATCTLSRPaperCoordinator.class); private DeviceCoordinator coordinator; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/atctlsrpaper/ATCTLSRPaperDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/atctlsrpaper/ATCTLSRPaperDeviceSupport.java new file mode 100644 index 0000000000..84c1df8995 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/atctlsrpaper/ATCTLSRPaperDeviceSupport.java @@ -0,0 +1,292 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.atctlsrpaper; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.net.Uri; +import android.provider.MediaStore; +import android.widget.Toast; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceBusyAction; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetProgressAction; +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + +public class ATCTLSRPaperDeviceSupport extends AbstractBTLESingleDeviceSupport { + public static final UUID UUID_SERVICE_MAIN = UUID.fromString("00001337-0000-1000-8000-00805f9b34fb"); + public static final UUID UUID_CHARACTERISTIC_MAIN = UUID.fromString("00001337-0000-1000-8000-00805f9b34fb"); + private static final Logger LOG = LoggerFactory.getLogger(ATCTLSRPaperDeviceSupport.class); + private static final byte[] COMMAND_GET_CONFIGURATION = new byte[]{0x00, 0x05}; + + private int epaper_width = 0; + private int epaper_height = 0; + private int epaper_colors = 0; + + private byte[] image_payload = null; + private byte[] block_data = null; + private int blocks_total = -1; + private int current_block = -1; + private int current_chunk = -1; + + + public ATCTLSRPaperDeviceSupport() { + super(LOG); + addSupportedService(UUID_SERVICE_MAIN); + } + + protected TransactionBuilder initializeDevice(TransactionBuilder builder) { + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); + getDevice().setFirmwareVersion("N/A"); + getDevice().setFirmwareVersion2("N/A"); + builder.requestMtu(512); + builder.notify(getCharacteristic(UUID_CHARACTERISTIC_MAIN), true); + builder.wait(300); + builder.write(getCharacteristic(UUID_CHARACTERISTIC_MAIN), COMMAND_GET_CONFIGURATION); + return builder; + } + + @Override + public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) { + super.onCharacteristicChanged(gatt, characteristic, value); + + UUID characteristicUUID = characteristic.getUuid(); + + LOG.info("Characteristic changed UUID: {}", characteristicUUID); + LOG.info("Characteristic changed value: {}", StringUtils.bytesToHex(characteristic.getValue())); + if (characteristicUUID.equals(UUID_CHARACTERISTIC_MAIN)) { + if (value[0] == 0x00) { + switch (value[1]) { + case 0x05: + handleConfigResponse(value); + return true; + case (byte) 0xc4: + handleNextChunkRequest(false); + return true; + case (byte) 0xc5: + handleNextChunkRequest(true); + return true; + case (byte) 0xc6: + handleNextBlockRequest(value); + return true; + case (byte) 0xc7: + handleFinishUploadRequest(true); + return true; + case (byte) 0xc8: + handleFinishUploadRequest(false); + return true; + } + } + } + return false; + } + + private int getCheckSum(byte[] buffer) { + int sum = 0; + for (byte val : buffer) { + sum += val & 0xff; + } + return sum; + } + + private byte[] encodeBlock(byte[] image, int block_nr) { + int block_length = 4096; + if (image.length < block_nr * 4096) { + return null; + } + if (image.length < (block_nr + 1) * 4096) { + block_length = image.length % 4096; + } + byte[] buffer = new byte[block_length + 4]; + buffer[0] = 0; // length filled in later + buffer[1] = 0; // length filled in later + buffer[2] = 0; // checksum filled in later + buffer[3] = 0; // checksum filled in later + System.arraycopy(image, block_nr * 4096, buffer, 4, block_length); + long checksum = getCheckSum(buffer) & 0xffffffffL; + buffer[0] = (byte) (block_length & 0xff); + buffer[1] = (byte) ((block_length >> 8) & 0xff); + buffer[2] = (byte) (checksum & 0xff); + buffer[3] = (byte) ((checksum >> 8) & 0xff); + return buffer; + + } + + private byte[] encodeImageChunk(byte[] block, int block_nr, int chunk_nr) { + int chunk_length = 230; + if (block.length < chunk_nr * 230) { + return null; + } + if (block.length < (chunk_nr + 1) * 230) { + chunk_length = block.length % 230; + } + byte[] buffer = new byte[235]; + buffer[0] = 0; // command filled in later + buffer[1] = 0; // command filled in later + buffer[2] = 0; // checksum filled in later + buffer[3] = (byte) block_nr; + buffer[4] = (byte) chunk_nr; + System.arraycopy(block, chunk_nr * 230, buffer, 5, chunk_length); + buffer[2] = (byte) getCheckSum(buffer); + buffer[1] = 0x65; // command + return buffer; + } + + private void handleFinishUploadRequest(boolean success) { + if (!success) { + GB.toast(getContext().getString(R.string.same_image_already_on_device), Toast.LENGTH_LONG, GB.WARN); + } + final TransactionBuilder builder = new TransactionBuilder("finish upload"); + builder.write(getCharacteristic(UUID_CHARACTERISTIC_MAIN), new byte[]{0x00, 0x03}); + if (getDevice().isBusy()) { + getDevice().unsetBusyTask(); + getDevice().sendDeviceUpdateIntent(getContext()); + } + current_chunk = -1; + current_block = -1; + blocks_total = -1; + } + + private void handleNextBlockRequest(byte[] value) { + final TransactionBuilder builder = new TransactionBuilder("send image start"); + builder.write(getCharacteristic(UUID_CHARACTERISTIC_MAIN), new byte[]{0x00, 0x02}); + current_chunk = 0; + current_block = value[11]; + block_data = encodeBlock(image_payload, current_block); + builder.write(getCharacteristic(UUID_CHARACTERISTIC_MAIN), encodeImageChunk(block_data, current_block, current_chunk)); + int progressPercent = (int) ((((float) current_block) / blocks_total) * 100); + builder.add(new SetProgressAction(getContext().getString(R.string.sending_image), true, progressPercent, getContext())); + builder.queue(getQueue()); + } + + private void handleNextChunkRequest(boolean success) { + final TransactionBuilder builder = new TransactionBuilder("send image chunk"); + if (success) { + current_chunk++; // only send next chunk when successful, else resend last chunk + } + builder.write(getCharacteristic(UUID_CHARACTERISTIC_MAIN), encodeImageChunk(block_data, current_block, current_chunk)); + builder.queue(getQueue()); + } + + private void handleConfigResponse(byte[] value) { + ByteBuffer buf = ByteBuffer.wrap(value); + buf.order(ByteOrder.LITTLE_ENDIAN); + + buf.position(6); + int version = buf.getInt(); + + buf.position(21); + boolean is_wh_swapped = buf.get() == 0x01; + + buf.position(24); + epaper_width = buf.getShort(); + epaper_height = buf.getShort(); + + buf.position(32); + epaper_colors = buf.get(); + + LOG.info("decoded data: version={}, width={}, height={}, nr_colors={}, w/h swapped={}", version, epaper_width, epaper_height, epaper_colors, is_wh_swapped); + + final TransactionBuilder builder = new TransactionBuilder("set initialized"); + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); + builder.queue(getQueue()); + } + + @Override + public void onInstallApp(Uri uri) { + try { + if (epaper_width == 0 || epaper_height == 0 || epaper_colors == 0) { + LOG.error("epaper config unknown"); + return; + } + Bitmap bitmap = MediaStore.Images.Media.getBitmap(GBApplication.getContext().getContentResolver(), uri); + final Bitmap bmpResized = Bitmap.createBitmap(epaper_width, epaper_height, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bmpResized); + final Rect rect = new Rect(0, 0, epaper_width, epaper_height); + canvas.rotate(180,canvas.getWidth()/2.0f, canvas.getHeight()/2.0f); + canvas.drawBitmap(bitmap, null, rect, null); + Matrix matrix = new Matrix(); + matrix.postRotate(180); + + + image_payload = new byte[((epaper_width * epaper_height) / 8) * epaper_colors]; + + // FIXME: different models with different colors + int[] colors = new int[]{ + 0x000000, // black + 0xff0000, // red + 0xffff00, // yellow + }; + + int nr_planes = (epaper_colors > 1) ? 2 : 1; + for (int plane = 0; plane < nr_planes; plane++) { + int byte_offset = 0; + int buffer_offset = plane * epaper_width * epaper_height / 8; + int color = colors[plane]; + for (int y = 0; y < epaper_height; y++) { + int bit_pos = 0; + int packed_byte = 0; + for (int x = 0; x < epaper_width; x++) { + int pixel = bmpResized.getPixel(x, y); + // put the third color in all planes; + if ((pixel & 0xffffff) == color || (epaper_colors > 2 && (pixel & 0xffffff) == colors[2])) { + packed_byte |= (1 << (7 - bit_pos)); + } + bit_pos++; + if (bit_pos == 8) { + image_payload[buffer_offset + byte_offset++] = (byte) (packed_byte & 0xff); + bit_pos = 0; + packed_byte = 0; + } + } + } + } + + int crc32 = CheckSums.getCRC32(image_payload); + ByteBuffer buf = ByteBuffer.allocate(19); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put((byte) 0x00); + buf.put((byte) 0x64); + buf.put((byte) 0xff); + buf.putLong(crc32 & 0xffffffffL); + buf.putInt(image_payload.length); + buf.put((byte) ((epaper_colors == 1) ? 0x20 : 0x21)); + buf.put((byte) 0x00); + buf.putShort((byte) 0x00); + + final TransactionBuilder builder = new TransactionBuilder("send image prepare"); + builder.write(getCharacteristic(UUID_CHARACTERISTIC_MAIN), buf.array()); + builder.add(new SetDeviceBusyAction(getDevice(), getContext().getString(R.string.sending_image), getContext())); + builder.queue(getQueue()); + blocks_total = image_payload.length / 4096 + 1; + + } catch (FileNotFoundException e) { + LOG.error("could not find file"); + } catch (IOException e) { + LOG.error("error decoding file"); + } + } + + @Override + public boolean useAutoConnect() { + return false; + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9c56487c4..58351d7b22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2049,6 +2049,7 @@ Bowers and Wilkins P series EarFun Air S EarFun Air Pro 4 + ATC_TLSR_Paper Choose export location General High-priority @@ -4080,4 +4081,6 @@ Maximum characters in event title Maximum characters in event description Target app on watch for calendar events + same image already on device + sending image