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 extends DeviceSupport> 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