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
This commit is contained in:
Andreas Shimokawa
2025-06-30 12:25:25 +02:00
parent 37c4972cb2
commit 0c2dad6f89
6 changed files with 402 additions and 1 deletions
+1
View File
@@ -5,6 +5,7 @@
* Initial support for Garmin Descent Mk3 * Initial support for Garmin Descent Mk3
* Initial support for Redmi Watch 5 * Initial support for Redmi Watch 5
* Initial support for Yawell rings R10 and R11 * Initial support for Yawell rings R10 and R11
* Initial support for ATC_TLSR_Paper (experimental)
* Experimental support for Amazfit Balance 2 * Experimental support for Amazfit Balance 2
* CMF Watch Pro: Firmware update * CMF Watch Pro: Firmware update
* Fossil/Skagen Hybrids: Add calendar support * Fossil/Skagen Hybrids: Add calendar support
@@ -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;
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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) {
}
}
@@ -31,6 +31,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.UnknownDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.UnknownDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.aawireless.AAWirelessCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.aawireless.AAWirelessCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.asteroidos.AsteroidOSDeviceCoordinator; 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.bandwpseries.BandWPSeriesDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.binary_sensor.coordinator.BinarySensorCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.binary_sensor.coordinator.BinarySensorCoordinator;
@@ -719,7 +720,8 @@ public enum DeviceType {
MARSTEK_B2500(MarstekB2500DeviceCoordinator.class), MARSTEK_B2500(MarstekB2500DeviceCoordinator.class),
GENERIC_HEADPHONES(GenericHeadphonesCoordinator.class), GENERIC_HEADPHONES(GenericHeadphonesCoordinator.class),
TEST(TestDeviceCoordinator.class), TEST(TestDeviceCoordinator.class),
ULTRAHUMAN_RING_AIR(UltrahumanDeviceCoordinator.class); ULTRAHUMAN_RING_AIR(UltrahumanDeviceCoordinator.class),
ATC_TLSR_PAPER(ATCTLSRPaperCoordinator.class);
private DeviceCoordinator coordinator; private DeviceCoordinator coordinator;
@@ -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;
}
}
+3
View File
@@ -2049,6 +2049,7 @@
<string name="devicetype_bandw_pseries">Bowers and Wilkins P series</string> <string name="devicetype_bandw_pseries">Bowers and Wilkins P series</string>
<string name="devicetype_earfun_air_s">EarFun Air S</string> <string name="devicetype_earfun_air_s">EarFun Air S</string>
<string name="devicetype_earfun_air_pro_4">EarFun Air Pro 4</string> <string name="devicetype_earfun_air_pro_4">EarFun Air Pro 4</string>
<string name="devicetype_atc_tlsr_paper">ATC_TLSR_Paper</string>
<string name="choose_auto_export_location">Choose export location</string> <string name="choose_auto_export_location">Choose export location</string>
<string name="notification_channel_name">General</string> <string name="notification_channel_name">General</string>
<string name="notification_channel_high_priority_name">High-priority</string> <string name="notification_channel_high_priority_name">High-priority</string>
@@ -4080,4 +4081,6 @@
<string name="pref_calendar_maximum_title_characters_title">Maximum characters in event title</string> <string name="pref_calendar_maximum_title_characters_title">Maximum characters in event title</string>
<string name="pref_calendar_maximum_description_characters_title">Maximum characters in event description</string> <string name="pref_calendar_maximum_description_characters_title">Maximum characters in event description</string>
<string name="pref_calendar_target_app_title">Target app on watch for calendar events</string> <string name="pref_calendar_target_app_title">Target app on watch for calendar events</string>
<string name="same_image_already_on_device">same image already on device</string>
<string name="sending_image">sending image</string>
</resources> </resources>