Initial ElegooLink Support (Only Centauri Carbon Tested)

This commit is contained in:
Dark98
2026-01-22 07:42:14 +00:00
parent 261ba81e06
commit f352a02b9f
13 changed files with 613 additions and 12 deletions
@@ -56,6 +56,7 @@ import ru.ytkab0bp.slicebeam.config.ConfigObject;
import ru.ytkab0bp.slicebeam.events.NeedDismissSnackbarEvent;
import ru.ytkab0bp.slicebeam.events.NeedSnackbarEvent;
import ru.ytkab0bp.slicebeam.fragment.BedFragment;
import ru.ytkab0bp.slicebeam.print_host.ElegooLinkClient;
import ru.ytkab0bp.slicebeam.recycler.SimpleRecyclerItem;
import ru.ytkab0bp.slicebeam.slic3r.GCodeProcessorResult;
import ru.ytkab0bp.slicebeam.slic3r.GCodeViewer;
@@ -76,7 +77,7 @@ public class SliceMenu extends ListBedMenu {
client.setMaxRetriesAndTimeout(0, 10000);
}
private final static List<String> SUPPORTED_SEND = Collections.singletonList("octoprint");
private final static List<String> SUPPORTED_SEND = Arrays.asList("octoprint", "elegoolink");
private int lastUid;
@Override
@@ -121,13 +122,14 @@ public class SliceMenu extends ListBedMenu {
String apiKey = obj.get("printhost_apikey");
if (SUPPORTED_SEND.contains(type) && !TextUtils.isEmpty(host)) {
String finalType = type;
items.add(new BedMenuItem(R.string.MenuSliceSendToPrinter, R.drawable.send_outline_28).onClick(v -> upload(finalType, host, apiKey, false)));
items.add(new BedMenuItem(R.string.MenuSliceSendToPrinterAndPrint, R.drawable.send_28).onClick(v -> upload(finalType, host, apiKey, true)));
ConfigObject finalObj = obj;
items.add(new BedMenuItem(R.string.MenuSliceSendToPrinter, R.drawable.send_outline_28).onClick(v -> upload(finalType, host, apiKey, false, finalObj)));
items.add(new BedMenuItem(R.string.MenuSliceSendToPrinterAndPrint, R.drawable.send_28).onClick(v -> upload(finalType, host, apiKey, true, finalObj)));
}
return items;
}
private void upload(String type, String host, String apiKey, boolean print) {
private void upload(String type, String host, String apiKey, boolean print, ConfigObject config) {
String name = fragment.getGlView().getRenderer().getGcodeResult().getRecommendedName();
switch (type) {
default:
@@ -172,7 +174,41 @@ public class SliceMenu extends ListBedMenu {
.show());
}
});
break;
case "elegoolink": {
if (!host.startsWith("http://") && !host.startsWith("https://")) {
host = "http://" + host;
}
String elegooTag = UUID.randomUUID().toString();
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.LOADING, R.string.MenuSliceSendToPrinterLoading).tag(elegooTag));
String finalHost = host;
final boolean timelapse = config != null && "1".equals(config.get("elegoolink_timelapse"));
final boolean bedLeveling = config != null && "1".equals(config.get("elegoolink_bed_leveling"));
int bedTypeVal = 0;
if (config != null) {
String bedTypeValue = config.get("elegoolink_bed_type");
if ("1".equals(bedTypeValue) || "pc".equalsIgnoreCase(bedTypeValue)) {
bedTypeVal = 1;
}
}
final int bedType = bedTypeVal;
new Thread(() -> {
ElegooLinkClient.Result result = ElegooLinkClient.upload(BedFragment.getTempGCodePath(), finalHost, name, print, timelapse, bedLeveling, bedType);
ViewUtils.postOnMainThread(() -> {
SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(elegooTag));
if (result.ok) {
SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(print ? SnackbarsLayout.Type.INFO : SnackbarsLayout.Type.DONE, print ? R.string.MenuSliceSendToPrinterPrintStarted : R.string.MenuSliceSendToPrinterOK));
} else {
new BeamAlertDialogBuilder(fragment.getContext())
.setTitle(R.string.MenuSliceSendToPrinterFailed)
.setMessage(result.error)
.setPositiveButton(android.R.string.ok, null)
.show();
}
});
}).start();
break;
}
}
}
@@ -124,6 +124,10 @@ public class ConfigObject implements ProfileListFragment.ProfileListItem {
custom.put("machine_min_extruding_rate", "0");
custom.put("machine_min_travel_rate", "0");
custom.put("elegoolink_timelapse", "0");
custom.put("elegoolink_bed_leveling", "0");
custom.put("elegoolink_bed_type", "pte");
custom.put("start_gcode", "G90 ; use absolute coordinates\\nM83 ; extruder relative mode\\nM104 S{is_nil(idle_temperature[0]) ? 150 : idle_temperature[0]} ; set temporary nozzle temp to prevent oozing during homing\\nM140 S{first_layer_bed_temperature[0]} ; set final bed temp\\nG4 S30 ; allow partial nozzle warmup\\nG28 ; home all axis\\nG1 Z50 F240\\nG1 X2.0 Y10 F3000\\nM104 S{first_layer_temperature[0]} ; set final nozzle temp\\nM190 S{first_layer_bed_temperature[0]} ; wait for bed temp to stabilize\\nM109 S{first_layer_temperature[0]} ; wait for nozzle temp to stabilize\\nG1 Z0.28 F240\\nG92 E0\\nG1 X2.0 Y140 E10 F1500 ; prime the nozzle\\nG1 X2.3 Y140 F5000\\nG92 E0\\nG1 X2.3 Y10 E10 F1200 ; prime the nozzle\\nG92 E0");
custom.put("end_gcode", "{if max_layer_z < max_print_height}G1 Z{z_offset+min(max_layer_z+2, max_print_height)} F600 ; Move print head up{endif}\\nG1 X5 Y{print_bed_max[1]*0.85} F{travel_speed*60} ; present print\\n{if max_layer_z < max_print_height-10}G1 Z{z_offset+min(max_layer_z+70, max_print_height-10)} F600 ; Move print head further up{endif}\\n{if max_layer_z < max_print_height*0.6}G1 Z{max_print_height*0.6} F600 ; Move print head further up{endif}\\nM140 S0 ; turn off heatbed\\nM104 S0 ; turn off temperature\\nM107 ; turn off fan\\nM84 X Y E ; disable motors");
@@ -9,6 +9,7 @@ import ru.ytkab0bp.slicebeam.SliceBeam;
import ru.ytkab0bp.slicebeam.config.ConfigObject;
import ru.ytkab0bp.slicebeam.recycler.SpaceItem;
import ru.ytkab0bp.slicebeam.slic3r.PrintConfigDef;
import ru.ytkab0bp.slicebeam.slic3r.ConfigOptionDef;
import ru.ytkab0bp.slicebeam.slic3r.Slic3rLocalization;
import ru.ytkab0bp.slicebeam.utils.ViewUtils;
@@ -175,6 +176,22 @@ public class PrinterConfigFragment extends ProfileListFragment {
new OptionElement(def.options.get("printhost_apikey"))
));
String hostType = null;
if (diffObject != null && diffObject.has("host_type")) {
hostType = diffObject.get("host_type");
}
if (hostType == null) {
hostType = currentConfig.get("host_type");
}
if ("elegoolink".equalsIgnoreCase(hostType)) {
list.addAll(Arrays.asList(
new OptionElement(new SubHeader("ElegooLink")),
new OptionElement(def.options.get("elegoolink_timelapse")),
new OptionElement(def.options.get("elegoolink_bed_leveling")),
new OptionElement(def.options.get("elegoolink_bed_type"))
));
}
return list;
}
@@ -237,4 +254,18 @@ public class PrinterConfigFragment extends ProfileListFragment {
// TODO: Reset print/filament profiles, maybe physical profiles?
SliceBeam.saveConfig();
}
@Override
protected void updateConfigField(ConfigOptionDef def, int i, String value) {
super.updateConfigField(def, i, value);
if ("host_type".equals(def.key)) {
onUpdateConfigItems();
}
}
@Override
protected void onUpdateConfigItems() {
setConfigItems(getConfigItems());
super.onUpdateConfigItems();
}
}
@@ -519,6 +519,7 @@ public abstract class ProfileListFragment extends Fragment {
@SuppressLint("NotifyDataSetChanged")
protected void setConfigItems(List<OptionElement> items) {
categoryElements.clear();
List<OptionWrapper> list = new ArrayList<>();
int j = 0;
for (int i = 0; i < items.size(); i++) {
@@ -539,7 +540,21 @@ public abstract class ProfileListFragment extends Fragment {
categoryElements.get(j - 1).add(w);
}
}
currentList = list;
List<OptionWrapper> expanded = new ArrayList<>();
int categoryIndex = 0;
for (OptionWrapper w : list) {
expanded.add(w);
if (w.categoryIndex == categoryIndex && unfolded.get(categoryIndex)) {
List<OptionWrapper> extra = categoryElements.get(categoryIndex);
if (extra != null) {
expanded.addAll(extra);
}
}
if (w.categoryIndex == categoryIndex) {
categoryIndex++;
}
}
currentList = expanded;
recyclerView.getAdapter().notifyDataSetChanged();
}
@@ -676,8 +691,8 @@ public abstract class ProfileListFragment extends Fragment {
String[] labels;
String[] values;
if (Objects.equals("host_type", def.key)) {
labels = new String[]{"OctoPrint"};
values = new String[]{"octoprint"};
labels = new String[]{"OctoPrint", "ElegooLink"};
values = new String[]{"octoprint", "elegoolink"};
} else {
labels = new String[def.enumLabels.length];
values = def.enumValues;
@@ -0,0 +1,441 @@
package ru.ytkab0bp.slicebeam.print_host;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okio.BufferedSink;
public final class ElegooLinkClient {
private static final int MAX_UPLOAD_PACKAGE_LENGTH = 1024 * 1024;
private static final MediaType OCTET_STREAM = MediaType.parse("application/octet-stream");
private ElegooLinkClient() {}
public static Result upload(File gcode, String host, String uploadName, boolean startPrint, boolean timelapse, boolean bedLeveling, int bedType) {
if (gcode == null || !gcode.exists()) {
return Result.error("G-code file not found.");
}
String finalName = (uploadName == null || uploadName.isEmpty()) ? gcode.getName() : uploadName;
String baseUrl = normalizeBaseUrl(host);
String uploadUrl = baseUrl + "/uploadFile/upload";
OkHttpClient client = new OkHttpClient.Builder()
.callTimeout(60, TimeUnit.SECONDS)
.build();
String md5;
try {
md5 = md5File(gcode);
} catch (Exception e) {
return Result.error("Failed to compute MD5: " + e.getMessage());
}
long size = gcode.length();
String uuid = UUID.randomUUID().toString().replace("-", "");
int packageCount = (int) ((size + MAX_UPLOAD_PACKAGE_LENGTH - 1) / MAX_UPLOAD_PACKAGE_LENGTH);
for (int i = 0; i < packageCount; i++) {
long offset = (long) MAX_UPLOAD_PACKAGE_LENGTH * i;
long length = Math.min(MAX_UPLOAD_PACKAGE_LENGTH, size - offset);
Result partRes = uploadPart(client, uploadUrl, gcode, finalName, md5, uuid, size, offset, length);
if (!partRes.ok) {
return partRes;
}
}
if (!startPrint) {
return Result.ok();
}
return startPrint(client, host, finalName, timelapse, bedLeveling, bedType);
}
private static Result uploadPart(OkHttpClient client, String url, File file, String uploadName, String md5, String uuid, long totalSize, long offset, long length) {
RequestBody fileBody = new FileSliceRequestBody(file, offset, length);
MultipartBody requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("Check", "1")
.addFormDataPart("S-File-MD5", md5)
.addFormDataPart("Offset", String.valueOf(offset))
.addFormDataPart("Uuid", uuid)
.addFormDataPart("TotalSize", String.valueOf(totalSize))
.addFormDataPart("File", uploadName, fileBody)
.build();
Request request = new Request.Builder()
.url(url)
.post(requestBody)
.build();
try (Response response = client.newCall(request).execute()) {
String body = response.body() != null ? response.body().string() : "";
if (!response.isSuccessful()) {
return Result.error("Upload failed: HTTP " + response.code());
}
if (!isElegooOk(body)) {
return Result.error(parseElegooError(body));
}
return Result.ok();
} catch (IOException e) {
return Result.error("Upload failed: " + e.getMessage());
}
}
private static Result startPrint(OkHttpClient client, String host, String filename, boolean timelapse, boolean bedLeveling, int bedType) {
WebSocketSession session = null;
String lastError = null;
for (String wsUrl : buildWebSocketCandidates(host)) {
WebSocketSession attempt = new WebSocketSession(client, wsUrl);
if (attempt.awaitOpen(10, TimeUnit.SECONDS)) {
session = attempt;
break;
}
lastError = attempt.failureSummary();
if (attempt.isTooManyClients()) {
return Result.error("Printer reports too many connected clients. Close the ElegooLink web UI and other apps, then try again.");
}
attempt.close();
}
if (session == null) {
if (lastError != null && !lastError.isEmpty()) {
return Result.error("Failed to connect to ElegooLink websocket. " + lastError);
}
return Result.error("Failed to connect to ElegooLink websocket.");
}
String requestId = UUID.randomUUID().toString().replace("-", "");
long timestamp = System.currentTimeMillis();
String json = "{"
+ "\"Id\":\"\","
+ "\"Data\":{"
+ "\"Cmd\":128,"
+ "\"Data\":{"
+ "\"Filename\":\"/local/" + filename + "\","
+ "\"StartLayer\":0,"
+ "\"Calibration_switch\":" + (bedLeveling ? 1 : 0) + ","
+ "\"PrintPlatformType\":" + (bedType != 0 ? 1 : 0) + ","
+ "\"Tlp_Switch\":" + (timelapse ? 1 : 0)
+ "},"
+ "\"RequestID\":\"" + requestId + "\","
+ "\"MainboardID\":\"\","
+ "\"TimeStamp\":" + timestamp + ","
+ "\"From\":1"
+ "}"
+ "}";
session.sendText(json);
String response = session.receiveText(30, TimeUnit.SECONDS);
if (response == null) {
session.close();
return Result.error("Start print timeout.");
}
try {
JSONObject root = new JSONObject(response);
JSONObject data = root.optJSONObject("Data");
if (data == null) {
session.close();
return Result.error("Invalid response from printer.");
}
int cmd = data.optInt("Cmd", -1);
if (cmd != 128) {
session.close();
return Result.error("Unexpected response from printer.");
}
JSONObject ackData = data.optJSONObject("Data");
int ack = ackData != null ? ackData.optInt("Ack", -1) : -1;
if (ack == 0) {
session.close();
return Result.ok();
}
String error = mapAckError(ack);
session.close();
return Result.error(error);
} catch (JSONException e) {
session.close();
return Result.error("Invalid response from printer.");
}
}
private static boolean isElegooOk(String body) {
try {
JSONObject root = new JSONObject(body);
String code = root.optString("code", "");
return "000000".equals(code);
} catch (JSONException e) {
return false;
}
}
private static String parseElegooError(String body) {
try {
JSONObject root = new JSONObject(body);
String code = root.optString("code", "unknown");
StringBuilder sb = new StringBuilder();
sb.append("ErrorCode: ").append(code);
JSONArray messages = root.optJSONArray("messages");
if (messages != null) {
for (int i = 0; i < messages.length(); i++) {
JSONObject msg = messages.optJSONObject(i);
if (msg != null) {
sb.append("\n").append(msg.optString("field", ""))
.append(":").append(msg.optString("message", ""));
}
}
}
return sb.toString();
} catch (JSONException e) {
return "Upload failed.";
}
}
private static String mapAckError(int ack) {
switch (ack) {
case 1:
return "The printer is busy.";
case 2:
return "The file is missing.";
case 3:
return "MD5 check failed.";
case 4:
return "File I/O error.";
case 5:
case 6:
return "File format or resolution is invalid.";
case 7:
return "File does not match the printer.";
default:
return "Unknown error. Error code: " + ack;
}
}
private static String normalizeBaseUrl(String host) {
String value = host.trim();
if (!value.contains("://")) {
value = "http://" + value;
}
if (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
return value;
}
private static List<String> buildWebSocketCandidates(String host) {
String h = sanitizeWebSocketHost(host);
List<String> urls = new ArrayList<>(1);
urls.add(String.format(Locale.US, "ws://%s:3030/websocket", h));
return urls;
}
private static String sanitizeWebSocketHost(String host) {
String base = normalizeBaseUrl(host);
try {
URI uri = new URI(base);
String h = uri.getHost() != null ? uri.getHost() : uri.getAuthority();
if (h != null && h.contains(":")) {
h = h.substring(0, h.indexOf(':'));
}
if (h == null || h.isEmpty()) {
h = host;
}
return wrapIpv6Host(h);
} catch (URISyntaxException e) {
String h = host;
int schemeIdx = h.indexOf("://");
if (schemeIdx != -1) {
h = h.substring(schemeIdx + 3);
}
int slashIdx = h.indexOf('/');
if (slashIdx != -1) {
h = h.substring(0, slashIdx);
}
int portIdx = h.indexOf(':');
if (portIdx != -1) {
h = h.substring(0, portIdx);
}
return wrapIpv6Host(h);
}
}
private static String wrapIpv6Host(String host) {
if (host == null || host.isEmpty()) {
return host;
}
if (host.indexOf(':') != -1 && !host.startsWith("[") && !host.endsWith("]")) {
return "[" + host + "]";
}
return host;
}
private static String md5File(File file) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("MD5");
try (BufferedInputStream in = new BufferedInputStream(new FileInputStream(file))) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
StringBuilder sb = new StringBuilder();
for (byte b : digest.digest()) {
sb.append(String.format(Locale.US, "%02x", b));
}
return sb.toString();
}
private static final class FileSliceRequestBody extends RequestBody {
private final File file;
private final long offset;
private final long length;
FileSliceRequestBody(File file, long offset, long length) {
this.file = file;
this.offset = offset;
this.length = length;
}
@Override
public MediaType contentType() {
return OCTET_STREAM;
}
@Override
public long contentLength() {
return length;
}
@Override
public void writeTo(BufferedSink sink) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
raf.seek(offset);
byte[] buffer = new byte[8192];
long remaining = length;
while (remaining > 0) {
int read = raf.read(buffer, 0, (int) Math.min(buffer.length, remaining));
if (read == -1) {
break;
}
sink.write(buffer, 0, read);
remaining -= read;
}
}
}
}
private static final class WebSocketSession extends okhttp3.WebSocketListener {
private final java.util.concurrent.BlockingQueue<String> messages = new java.util.concurrent.LinkedBlockingQueue<>();
private final okhttp3.WebSocket socket;
private volatile boolean opened;
private volatile String failureBody;
private volatile okhttp3.Response failureResponse;
private volatile Throwable failure;
WebSocketSession(OkHttpClient client, String url) {
Request request = new Request.Builder().url(url).build();
socket = client.newWebSocket(request, this);
}
boolean awaitOpen(long timeout, TimeUnit unit) {
try {
long deadline = System.nanoTime() + unit.toNanos(timeout);
while (!opened && System.nanoTime() < deadline) {
Thread.sleep(50);
}
return opened;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
void sendText(String message) {
socket.send(message);
}
String receiveText(long timeout, TimeUnit unit) {
try {
return messages.poll(timeout, unit);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
void close() {
socket.close(1000, "done");
}
String failureSummary() {
if (failureResponse != null) {
return "HTTP " + failureResponse.code();
}
if (failure != null) {
return failure.getMessage();
}
return "";
}
boolean isTooManyClients() {
return failureBody != null && failureBody.toLowerCase(Locale.US).contains("too many client");
}
@Override
public void onOpen(okhttp3.WebSocket webSocket, okhttp3.Response response) {
opened = true;
}
@Override
public void onMessage(okhttp3.WebSocket webSocket, String text) {
messages.offer(text);
}
@Override
public void onFailure(okhttp3.WebSocket webSocket, Throwable t, okhttp3.Response response) {
failure = t;
failureResponse = response;
if (response != null && response.body() != null) {
try {
failureBody = response.body().string();
} catch (IOException ignored) {
}
}
}
}
public static final class Result {
public final boolean ok;
public final String error;
private Result(boolean ok, String error) {
this.ok = ok;
this.error = error;
}
public static Result ok() {
return new Result(true, null);
}
public static Result error(String error) {
return new Result(false, error);
}
}
}
@@ -95,7 +95,8 @@ public class Slic3rConfigWrapper {
"machine_max_acceleration_x", "machine_max_acceleration_y", "machine_max_acceleration_z", "machine_max_acceleration_e",
"machine_max_feedrate_x", "machine_max_feedrate_y", "machine_max_feedrate_z", "machine_max_feedrate_e",
"machine_min_extruding_rate", "machine_min_travel_rate",
"machine_max_jerk_x", "machine_max_jerk_y", "machine_max_jerk_z", "machine_max_jerk_e"
"machine_max_jerk_x", "machine_max_jerk_y", "machine_max_jerk_z", "machine_max_jerk_e",
"elegoolink_timelapse", "elegoolink_bed_leveling", "elegoolink_bed_type"
);
public final static List<String> PHYSICAL_PRINTER_CONFIG_KEYS = Arrays.asList(
"preset_name", // temporary option to compatibility with older Slicer