From e7aaa7ead590526c0d37a2220bc9b975250e5c23 Mon Sep 17 00:00:00 2001 From: "Andrea C. G. Mennucci" Date: Thu, 13 Aug 2026 10:59:43 +0200 Subject: [PATCH 1/4] Add exclusive printer-link access API --- docs/printer_link_exclusive_access.md | 36 ++++++++ esp3d/src/core/esp3d_commands.cpp | 15 ++++ .../modules/http/handlers/handle-command.cpp | 7 ++ .../printer_link/printer_link_service.cpp | 82 +++++++++++++++++++ .../printer_link/printer_link_service.h | 49 +++++++++++ .../modules/serial/serial_service_esp32.cpp | 3 + .../modules/serial/serial_service_esp8266.cpp | 7 +- .../modules/usb-serial/usb_serial_service.cpp | 4 +- .../modules/websocket/websocket_server.cpp | 2 + 9 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 docs/printer_link_exclusive_access.md create mode 100644 esp3d/src/modules/printer_link/printer_link_service.cpp create mode 100644 esp3d/src/modules/printer_link/printer_link_service.h diff --git a/docs/printer_link_exclusive_access.md b/docs/printer_link_exclusive_access.md new file mode 100644 index 00000000..83b3c5a7 --- /dev/null +++ b/docs/printer_link_exclusive_access.md @@ -0,0 +1,36 @@ +# Exclusive Printer-Link Access + +`PrinterLinkService` lets a protocol temporarily reserve the connection to the +printer. This is useful for binary or otherwise stateful protocols that cannot +share the byte stream with terminal commands or automatic polling. + +## API + +Capture the link with an owner name and an optional receive callback: + +```cpp +printer_link_service.acquire("binary-transfer", receiveData, context); +``` + +While captured: + +- all printer RX is delivered only to `receiveData`; +- a null callback deliberately discards printer RX; +- normal commands, including WebUI polling, are rejected before serial TX; +- other clients cannot capture the link; +- the WebUI receives `printerLink:captured:binary-transfer`. + +Release it using the same owner name: + +```cpp +printer_link_service.release("binary-transfer"); +``` + +Only the current owner can release the link. Release restores normal terminal +RX and printer TX and emits `printerLink:released`. A newly connected WebUI is +sent the current state immediately, so it can disable its terminal input and +polling controls even when capture began before the page connected. + +Every successful `acquire()` must have a matching `release()` on success, +cancellation, timeout, and error paths. The service does not transmit protocol +bytes itself; the owner continues to use the selected serial service directly. diff --git a/esp3d/src/core/esp3d_commands.cpp b/esp3d/src/core/esp3d_commands.cpp index bc6bd5a2..549867ec 100644 --- a/esp3d/src/core/esp3d_commands.cpp +++ b/esp3d/src/core/esp3d_commands.cpp @@ -89,6 +89,7 @@ const char *esp3dmsgstr[] = {"head", "core", "tail", "unique"}; #if defined(USB_SERIAL_FEATURE) #include "../modules/usb-serial/usb_serial_service.h" #endif // USB_SERIAL_FEATURE +#include "../modules/printer_link/printer_link_service.h" ESP3DCommands esp3d_commands; @@ -1329,6 +1330,13 @@ bool ESP3DCommands::dispatch(ESP3DMessage *msg) { #if COMMUNICATION_PROTOCOL == RAW_SERIAL case ESP3DClientType::serial: esp3d_log("Serial message"); + if (printer_link_service.captured()) { + esp3d_log_e("Printer link is captured by %s", + printer_link_service.owner()); + esp3d_message_manager.deleteMsg(msg); + sendOk = false; + break; + } if (!esp3d_serial_service.dispatch(msg)) { sendOk = false; esp3d_log_e("Serial dispatch failed"); @@ -1337,6 +1345,13 @@ bool ESP3DCommands::dispatch(ESP3DMessage *msg) { #if defined(USB_SERIAL_FEATURE) case ESP3DClientType::usb_serial: esp3d_log("USB Serial message"); + if (printer_link_service.captured()) { + esp3d_log_e("Printer link is captured by %s", + printer_link_service.owner()); + esp3d_message_manager.deleteMsg(msg); + sendOk = false; + break; + } if (!esp3d_usb_serial_service.dispatch(msg)) { sendOk = false; esp3d_log_e("USB Serial dispatch failed"); diff --git a/esp3d/src/modules/http/handlers/handle-command.cpp b/esp3d/src/modules/http/handlers/handle-command.cpp index 580694b3..d919d783 100644 --- a/esp3d/src/modules/http/handlers/handle-command.cpp +++ b/esp3d/src/modules/http/handlers/handle-command.cpp @@ -31,6 +31,7 @@ #include "../../../core/esp3d_settings.h" #include "../../../core/esp3d_string.h" #include "../../authentication/authentication_service.h" +#include "../../printer_link/printer_link_service.h" // Handle web command query and send answer////////////////////////////// @@ -87,6 +88,12 @@ void HTTP_Server::handle_web_command() { } } else { HTTP_Server::set_http_headers(); + if (printer_link_service.captured()) { + String response = "Printer link is captured by "; + response += printer_link_service.owner(); + _webserver->send(423, "text/plain", response); + return; + } // the command is not ESP3D so it will be forwarded to the output client // no need to wait to answer then _webserver->send(200, "text/plain", "ESP3D says: command forwarded"); diff --git a/esp3d/src/modules/printer_link/printer_link_service.cpp b/esp3d/src/modules/printer_link/printer_link_service.cpp new file mode 100644 index 00000000..e180c1ab --- /dev/null +++ b/esp3d/src/modules/printer_link/printer_link_service.cpp @@ -0,0 +1,82 @@ +/* + printer_link_service.cpp - Exclusive access to the printer connection + + Copyright (c) 2026 ESP3D contributors + + This code is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. +*/ + +#include "printer_link_service.h" + +#if defined(HTTP_FEATURE) +#include "../websocket/websocket_server.h" +#endif + +PrinterLinkService printer_link_service; + +bool PrinterLinkService::acquire(const char *owner, + PrinterLinkRxHandler handler, + void *context) { + if (!owner || !owner[0] || _captured) return false; + + _ownerKey = owner; + _owner = owner; + _owner.trim(); + _owner.replace(":", "_"); + _owner.replace("\r", "_"); + _owner.replace("\n", "_"); + if (!_owner.length()) { + _ownerKey = ""; + return false; + } + if (_owner.length() > 32) _owner.remove(32); + + _handler = handler; + _context = context; + _captured = true; + notifyWebUi(); + return true; +} + +bool PrinterLinkService::release(const char *owner) { + if (!_captured || !owner || _ownerKey != owner) return false; + + // Stop diverting RX before invalidating the callback and its context. + _captured = false; + _handler = nullptr; + _context = nullptr; + _ownerKey = ""; + _owner = ""; + notifyWebUi(); + return true; +} + +bool PrinterLinkService::consumeRx(const uint8_t *data, size_t size) { + if (!_captured) return false; + if (_handler && data && size) _handler(data, size, _context); + return true; +} + +String PrinterLinkService::eventMessage() const { + if (_captured) return "printerLink:captured:" + _owner; + return "printerLink:released"; +} + +void PrinterLinkService::notifyWebUi() const { +#if defined(HTTP_FEATURE) + if (websocket_terminal_server.started()) + websocket_terminal_server.pushMSG(eventMessage().c_str()); +#endif +} + +void PrinterLinkService::notifyWebUi(uint8_t clientId) const { +#if defined(HTTP_FEATURE) + if (websocket_terminal_server.started()) + websocket_terminal_server.pushMSG(clientId, eventMessage().c_str()); +#else + (void)clientId; +#endif +} diff --git a/esp3d/src/modules/printer_link/printer_link_service.h b/esp3d/src/modules/printer_link/printer_link_service.h new file mode 100644 index 00000000..a6abf6de --- /dev/null +++ b/esp3d/src/modules/printer_link/printer_link_service.h @@ -0,0 +1,49 @@ +/* + printer_link_service.h - Exclusive access to the printer connection + + Copyright (c) 2026 ESP3D contributors + + This code is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. +*/ + +#pragma once + +#include "../../include/esp3d_config.h" + +using PrinterLinkRxHandler = + void (*)(const uint8_t *data, size_t size, void *context); + +class PrinterLinkService final { + public: + // Capture all printer RX and block normal printer TX. The owner string is + // also exposed to the WebUI. A null handler captures and discards RX. + bool acquire(const char *owner, PrinterLinkRxHandler handler = nullptr, + void *context = nullptr); + + // Release only when the caller supplies the owner used by acquire(). + bool release(const char *owner); + + bool captured() const { return _captured; } + const char *owner() const { return _owner.c_str(); } + + // Returns true when RX belongs to the current exclusive owner. + bool consumeRx(const uint8_t *data, size_t size); + + // Send current state to one newly connected WebUI client. + void notifyWebUi(uint8_t clientId) const; + + private: + void notifyWebUi() const; + String eventMessage() const; + + volatile bool _captured = false; + String _ownerKey; + String _owner; + PrinterLinkRxHandler _handler = nullptr; + void *_context = nullptr; +}; + +extern PrinterLinkService printer_link_service; diff --git a/esp3d/src/modules/serial/serial_service_esp32.cpp b/esp3d/src/modules/serial/serial_service_esp32.cpp index b341fae9..3d86c4c0 100644 --- a/esp3d/src/modules/serial/serial_service_esp32.cpp +++ b/esp3d/src/modules/serial/serial_service_esp32.cpp @@ -25,6 +25,7 @@ #include "../../core/esp3d_settings.h" #include "../../core/esp3d_string.h" #include "../authentication/authentication_service.h" +#include "../printer_link/printer_link_service.h" #include "serial_service.h" #define SERIAL_COMMUNICATION_TIMEOUT 500 @@ -102,6 +103,8 @@ void ESP3DSerialService::receiveCb() { } //take the char count--; + const uint8_t received = static_cast(data); + if (printer_link_service.consumeRx(&received, 1)) continue; _buffer[_buffer_size] = (uint8_t)data; //check what next step is if (esp3d_string::isRealTimeCommand(_buffer[_buffer_size])) { diff --git a/esp3d/src/modules/serial/serial_service_esp8266.cpp b/esp3d/src/modules/serial/serial_service_esp8266.cpp index 7b6fa0e9..2f13a1c3 100644 --- a/esp3d/src/modules/serial/serial_service_esp8266.cpp +++ b/esp3d/src/modules/serial/serial_service_esp8266.cpp @@ -29,6 +29,7 @@ #include "../mks/mks_service.h" #endif // COMMUNICATION_PROTOCOL == MKS_SERIAL #include "../authentication/authentication_service.h" +#include "../printer_link/printer_link_service.h" #define MAX_SERIAL 2 HardwareSerial *Serials[MAX_SERIAL] = {&Serial, &Serial1}; @@ -132,7 +133,9 @@ void ESP3DSerialService::handle() { size_t count = readBytes(sbuf, len); // push to buffer if (count > 0) { - push2buffer(sbuf, count); + if (!printer_link_service.consumeRx(sbuf, count)) { + push2buffer(sbuf, count); + } } // freen buffer free(sbuf); @@ -276,4 +279,4 @@ void ESP3DSerialService::swap() { #endif // COMMUNICATION_PROTOCOL == MKS_SERIAL || COMMUNICATION_PROTOCOL == // RAW_SERIAL -#endif // ARDUINO_ARCH_ESP8266 \ No newline at end of file +#endif // ARDUINO_ARCH_ESP8266 diff --git a/esp3d/src/modules/usb-serial/usb_serial_service.cpp b/esp3d/src/modules/usb-serial/usb_serial_service.cpp index 794ce411..a396819a 100644 --- a/esp3d/src/modules/usb-serial/usb_serial_service.cpp +++ b/esp3d/src/modules/usb-serial/usb_serial_service.cpp @@ -24,6 +24,7 @@ #include "../../core/esp3d_settings.h" #include "../../core/esp3d_string.h" #include "../authentication/authentication_service.h" +#include "../printer_link/printer_link_service.h" #include "usb_serial_service.h" #if defined(NOTIFICATION_FEATURE) @@ -172,6 +173,7 @@ void ESP3DUsbSerialService::receiveCb(const uint8_t *data, size_t data_len, if (!started()) { return; } + if (printer_link_service.consumeRx(data, data_len)) return; if (xSemaphoreTake(_buffer_mutex, portMAX_DELAY) == pdTRUE) { for (size_t i = 0; i < data_len; i++) { if (esp3d_string::isRealTimeCommand(data[i])) { @@ -508,4 +510,4 @@ bool ESP3DUsbSerialService::dispatch(ESP3DMessage *message) { return done; } -#endif // USB_SERIAL_FEATURE \ No newline at end of file +#endif // USB_SERIAL_FEATURE diff --git a/esp3d/src/modules/websocket/websocket_server.cpp b/esp3d/src/modules/websocket/websocket_server.cpp index f3546c2d..acb65477 100644 --- a/esp3d/src/modules/websocket/websocket_server.cpp +++ b/esp3d/src/modules/websocket/websocket_server.cpp @@ -30,6 +30,7 @@ #include "../../core/esp3d_settings.h" #include "../../core/esp3d_string.h" #include "../authentication/authentication_service.h" +#include "../printer_link/printer_link_service.h" #include "websocket_server.h" WebSocket_Server websocket_terminal_server("webui-v3", @@ -142,6 +143,7 @@ void handle_Websocket_Terminal_Event(uint8_t num, uint8_t type, websocket_terminal_server.pushMSG(num, msg.c_str()); msg = "activeID:" + String(num); websocket_terminal_server.pushMSG(msg.c_str()); + printer_link_service.notifyWebUi(num); websocket_terminal_server.enableOnly(num); esp3d_log_d("[%u] Socket connected port %d", num, websocket_terminal_server.port()); From 0aac711cb09307d1bf0cc372f567293630c4b29c Mon Sep 17 00:00:00 2001 From: "Andrea C. G. Mennucci" Date: Wed, 12 Aug 2026 22:42:45 +0200 Subject: [PATCH 2/4] Vendor heatshrink 0.4.1 encoder --- libraries/heatshrink-0.4.1/LICENSE | 14 + libraries/heatshrink-0.4.1/README.ESP3D.md | 9 + libraries/heatshrink-0.4.1/library.properties | 10 + .../heatshrink-0.4.1/src/heatshrink_common.h | 20 + .../heatshrink-0.4.1/src/heatshrink_config.h | 26 + .../heatshrink-0.4.1/src/heatshrink_encoder.c | 604 ++++++++++++++++++ .../heatshrink-0.4.1/src/heatshrink_encoder.h | 109 ++++ 7 files changed, 792 insertions(+) create mode 100644 libraries/heatshrink-0.4.1/LICENSE create mode 100644 libraries/heatshrink-0.4.1/README.ESP3D.md create mode 100644 libraries/heatshrink-0.4.1/library.properties create mode 100644 libraries/heatshrink-0.4.1/src/heatshrink_common.h create mode 100644 libraries/heatshrink-0.4.1/src/heatshrink_config.h create mode 100644 libraries/heatshrink-0.4.1/src/heatshrink_encoder.c create mode 100644 libraries/heatshrink-0.4.1/src/heatshrink_encoder.h diff --git a/libraries/heatshrink-0.4.1/LICENSE b/libraries/heatshrink-0.4.1/LICENSE new file mode 100644 index 00000000..9132cb6a --- /dev/null +++ b/libraries/heatshrink-0.4.1/LICENSE @@ -0,0 +1,14 @@ +Copyright (c) 2013-2015, Scott Vokes +All rights reserved. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/libraries/heatshrink-0.4.1/README.ESP3D.md b/libraries/heatshrink-0.4.1/README.ESP3D.md new file mode 100644 index 00000000..ad947ce1 --- /dev/null +++ b/libraries/heatshrink-0.4.1/README.ESP3D.md @@ -0,0 +1,9 @@ +# Vendored heatshrink encoder + +This directory contains the encoder and license from the upstream +`v0.4.1` tag (`b9ac05e`) of +. + +Only the encoder is required. Marlin provides the matching decoder and +announces its window and lookahead parameters during Binary File Transfer +capability negotiation. diff --git a/libraries/heatshrink-0.4.1/library.properties b/libraries/heatshrink-0.4.1/library.properties new file mode 100644 index 00000000..0fe2972d --- /dev/null +++ b/libraries/heatshrink-0.4.1/library.properties @@ -0,0 +1,10 @@ +name=heatshrink +version=0.4.1 +author=Scott Vokes +maintainer=Scott Vokes +sentence=An embedded data compression library. +paragraph=Vendored heatshrink encoder used for Marlin Binary File Transfer. +category=Data Processing +url=https://github.com/atomicobject/heatshrink +architectures=* + diff --git a/libraries/heatshrink-0.4.1/src/heatshrink_common.h b/libraries/heatshrink-0.4.1/src/heatshrink_common.h new file mode 100644 index 00000000..bc89774f --- /dev/null +++ b/libraries/heatshrink-0.4.1/src/heatshrink_common.h @@ -0,0 +1,20 @@ +#ifndef HEATSHRINK_H +#define HEATSHRINK_H + +#define HEATSHRINK_AUTHOR "Scott Vokes " +#define HEATSHRINK_URL "https://github.com/atomicobject/heatshrink" + +/* Version 0.4.1 */ +#define HEATSHRINK_VERSION_MAJOR 0 +#define HEATSHRINK_VERSION_MINOR 4 +#define HEATSHRINK_VERSION_PATCH 1 + +#define HEATSHRINK_MIN_WINDOW_BITS 4 +#define HEATSHRINK_MAX_WINDOW_BITS 15 + +#define HEATSHRINK_MIN_LOOKAHEAD_BITS 3 + +#define HEATSHRINK_LITERAL_MARKER 0x01 +#define HEATSHRINK_BACKREF_MARKER 0x00 + +#endif diff --git a/libraries/heatshrink-0.4.1/src/heatshrink_config.h b/libraries/heatshrink-0.4.1/src/heatshrink_config.h new file mode 100644 index 00000000..13135b93 --- /dev/null +++ b/libraries/heatshrink-0.4.1/src/heatshrink_config.h @@ -0,0 +1,26 @@ +#ifndef HEATSHRINK_CONFIG_H +#define HEATSHRINK_CONFIG_H + +/* Should functionality assuming dynamic allocation be used? */ +#ifndef HEATSHRINK_DYNAMIC_ALLOC +#define HEATSHRINK_DYNAMIC_ALLOC 1 +#endif + +#if HEATSHRINK_DYNAMIC_ALLOC + /* Optional replacement of malloc/free */ + #define HEATSHRINK_MALLOC(SZ) malloc(SZ) + #define HEATSHRINK_FREE(P, SZ) free(P) +#else + /* Required parameters for static configuration */ + #define HEATSHRINK_STATIC_INPUT_BUFFER_SIZE 32 + #define HEATSHRINK_STATIC_WINDOW_BITS 8 + #define HEATSHRINK_STATIC_LOOKAHEAD_BITS 4 +#endif + +/* Turn on logging for debugging. */ +#define HEATSHRINK_DEBUGGING_LOGS 0 + +/* Use indexing for faster compression. (This requires additional space.) */ +#define HEATSHRINK_USE_INDEX 1 + +#endif diff --git a/libraries/heatshrink-0.4.1/src/heatshrink_encoder.c b/libraries/heatshrink-0.4.1/src/heatshrink_encoder.c new file mode 100644 index 00000000..edf4abeb --- /dev/null +++ b/libraries/heatshrink-0.4.1/src/heatshrink_encoder.c @@ -0,0 +1,604 @@ +#include +#include +#include +#include "heatshrink_encoder.h" + +typedef enum { + HSES_NOT_FULL, /* input buffer not full enough */ + HSES_FILLED, /* buffer is full */ + HSES_SEARCH, /* searching for patterns */ + HSES_YIELD_TAG_BIT, /* yield tag bit */ + HSES_YIELD_LITERAL, /* emit literal byte */ + HSES_YIELD_BR_INDEX, /* yielding backref index */ + HSES_YIELD_BR_LENGTH, /* yielding backref length */ + HSES_SAVE_BACKLOG, /* copying buffer to backlog */ + HSES_FLUSH_BITS, /* flush bit buffer */ + HSES_DONE, /* done */ +} HSE_state; + +#if HEATSHRINK_DEBUGGING_LOGS +#include +#include +#include +#define LOG(...) fprintf(stderr, __VA_ARGS__) +#define ASSERT(X) assert(X) +static const char *state_names[] = { + "not_full", + "filled", + "search", + "yield_tag_bit", + "yield_literal", + "yield_br_index", + "yield_br_length", + "save_backlog", + "flush_bits", + "done", +}; +#else +#define LOG(...) /* no-op */ +#define ASSERT(X) /* no-op */ +#endif + +// Encoder flags +enum { + FLAG_IS_FINISHING = 0x01, +}; + +typedef struct { + uint8_t *buf; /* output buffer */ + size_t buf_size; /* buffer size */ + size_t *output_size; /* bytes pushed to buffer, so far */ +} output_info; + +#define MATCH_NOT_FOUND ((uint16_t)-1) + +static uint16_t get_input_offset(heatshrink_encoder *hse); +static uint16_t get_input_buffer_size(heatshrink_encoder *hse); +static uint16_t get_lookahead_size(heatshrink_encoder *hse); +static void add_tag_bit(heatshrink_encoder *hse, output_info *oi, uint8_t tag); +static int can_take_byte(output_info *oi); +static int is_finishing(heatshrink_encoder *hse); +static void save_backlog(heatshrink_encoder *hse); + +/* Push COUNT (max 8) bits to the output buffer, which has room. */ +static void push_bits(heatshrink_encoder *hse, uint8_t count, uint8_t bits, + output_info *oi); +static uint8_t push_outgoing_bits(heatshrink_encoder *hse, output_info *oi); +static void push_literal_byte(heatshrink_encoder *hse, output_info *oi); + +#if HEATSHRINK_DYNAMIC_ALLOC +heatshrink_encoder *heatshrink_encoder_alloc(uint8_t window_sz2, + uint8_t lookahead_sz2) { + if ((window_sz2 < HEATSHRINK_MIN_WINDOW_BITS) || + (window_sz2 > HEATSHRINK_MAX_WINDOW_BITS) || + (lookahead_sz2 < HEATSHRINK_MIN_LOOKAHEAD_BITS) || + (lookahead_sz2 >= window_sz2)) { + return NULL; + } + + /* Note: 2 * the window size is used because the buffer needs to fit + * (1 << window_sz2) bytes for the current input, and an additional + * (1 << window_sz2) bytes for the previous buffer of input, which + * will be scanned for useful backreferences. */ + size_t buf_sz = (2 << window_sz2); + + heatshrink_encoder *hse = HEATSHRINK_MALLOC(sizeof(*hse) + buf_sz); + if (hse == NULL) { return NULL; } + hse->window_sz2 = window_sz2; + hse->lookahead_sz2 = lookahead_sz2; + heatshrink_encoder_reset(hse); + +#if HEATSHRINK_USE_INDEX + size_t index_sz = buf_sz*sizeof(uint16_t); + hse->search_index = HEATSHRINK_MALLOC(index_sz + sizeof(struct hs_index)); + if (hse->search_index == NULL) { + HEATSHRINK_FREE(hse, sizeof(*hse) + buf_sz); + return NULL; + } + hse->search_index->size = index_sz; +#endif + + LOG("-- allocated encoder with buffer size of %zu (%u byte input size)\n", + buf_sz, get_input_buffer_size(hse)); + return hse; +} + +void heatshrink_encoder_free(heatshrink_encoder *hse) { + size_t buf_sz = (2 << HEATSHRINK_ENCODER_WINDOW_BITS(hse)); +#if HEATSHRINK_USE_INDEX + size_t index_sz = sizeof(struct hs_index) + hse->search_index->size; + HEATSHRINK_FREE(hse->search_index, index_sz); + (void)index_sz; +#endif + HEATSHRINK_FREE(hse, sizeof(heatshrink_encoder) + buf_sz); + (void)buf_sz; +} +#endif + +void heatshrink_encoder_reset(heatshrink_encoder *hse) { + size_t buf_sz = (2 << HEATSHRINK_ENCODER_WINDOW_BITS(hse)); + memset(hse->buffer, 0, buf_sz); + hse->input_size = 0; + hse->state = HSES_NOT_FULL; + hse->match_scan_index = 0; + hse->flags = 0; + hse->bit_index = 0x80; + hse->current_byte = 0x00; + hse->match_length = 0; + + hse->outgoing_bits = 0x0000; + hse->outgoing_bits_count = 0; + + #ifdef LOOP_DETECT + hse->loop_detect = (uint32_t)-1; + #endif +} + +HSE_sink_res heatshrink_encoder_sink(heatshrink_encoder *hse, + uint8_t *in_buf, size_t size, size_t *input_size) { + if ((hse == NULL) || (in_buf == NULL) || (input_size == NULL)) { + return HSER_SINK_ERROR_NULL; + } + + /* Sinking more content after saying the content is done, tsk tsk */ + if (is_finishing(hse)) { return HSER_SINK_ERROR_MISUSE; } + + /* Sinking more content before processing is done */ + if (hse->state != HSES_NOT_FULL) { return HSER_SINK_ERROR_MISUSE; } + + uint16_t write_offset = get_input_offset(hse) + hse->input_size; + uint16_t ibs = get_input_buffer_size(hse); + uint16_t rem = ibs - hse->input_size; + uint16_t cp_sz = rem < size ? rem : size; + + memcpy(&hse->buffer[write_offset], in_buf, cp_sz); + *input_size = cp_sz; + hse->input_size += cp_sz; + + LOG("-- sunk %u bytes (of %zu) into encoder at %d, input buffer now has %u\n", + cp_sz, size, write_offset, hse->input_size); + if (cp_sz == rem) { + LOG("-- internal buffer is now full\n"); + hse->state = HSES_FILLED; + } + + return HSER_SINK_OK; +} + + +/*************** + * Compression * + ***************/ + +static uint16_t find_longest_match(heatshrink_encoder *hse, uint16_t start, + uint16_t end, const uint16_t maxlen, uint16_t *match_length); +static void do_indexing(heatshrink_encoder *hse); + +static HSE_state st_step_search(heatshrink_encoder *hse); +static HSE_state st_yield_tag_bit(heatshrink_encoder *hse, + output_info *oi); +static HSE_state st_yield_literal(heatshrink_encoder *hse, + output_info *oi); +static HSE_state st_yield_br_index(heatshrink_encoder *hse, + output_info *oi); +static HSE_state st_yield_br_length(heatshrink_encoder *hse, + output_info *oi); +static HSE_state st_save_backlog(heatshrink_encoder *hse); +static HSE_state st_flush_bit_buffer(heatshrink_encoder *hse, + output_info *oi); + +HSE_poll_res heatshrink_encoder_poll(heatshrink_encoder *hse, + uint8_t *out_buf, size_t out_buf_size, size_t *output_size) { + if ((hse == NULL) || (out_buf == NULL) || (output_size == NULL)) { + return HSER_POLL_ERROR_NULL; + } + if (out_buf_size == 0) { + LOG("-- MISUSE: output buffer size is 0\n"); + return HSER_POLL_ERROR_MISUSE; + } + *output_size = 0; + + output_info oi; + oi.buf = out_buf; + oi.buf_size = out_buf_size; + oi.output_size = output_size; + + while (1) { + LOG("-- polling, state %u (%s), flags 0x%02x\n", + hse->state, state_names[hse->state], hse->flags); + + uint8_t in_state = hse->state; + switch (in_state) { + case HSES_NOT_FULL: + return HSER_POLL_EMPTY; + case HSES_FILLED: + do_indexing(hse); + hse->state = HSES_SEARCH; + break; + case HSES_SEARCH: + hse->state = st_step_search(hse); + break; + case HSES_YIELD_TAG_BIT: + hse->state = st_yield_tag_bit(hse, &oi); + break; + case HSES_YIELD_LITERAL: + hse->state = st_yield_literal(hse, &oi); + break; + case HSES_YIELD_BR_INDEX: + hse->state = st_yield_br_index(hse, &oi); + break; + case HSES_YIELD_BR_LENGTH: + hse->state = st_yield_br_length(hse, &oi); + break; + case HSES_SAVE_BACKLOG: + hse->state = st_save_backlog(hse); + break; + case HSES_FLUSH_BITS: + hse->state = st_flush_bit_buffer(hse, &oi); + case HSES_DONE: + return HSER_POLL_EMPTY; + default: + LOG("-- bad state %s\n", state_names[hse->state]); + return HSER_POLL_ERROR_MISUSE; + } + + if (hse->state == in_state) { + /* Check if output buffer is exhausted. */ + if (*output_size == out_buf_size) return HSER_POLL_MORE; + } + } +} + +HSE_finish_res heatshrink_encoder_finish(heatshrink_encoder *hse) { + if (hse == NULL) { return HSER_FINISH_ERROR_NULL; } + LOG("-- setting is_finishing flag\n"); + hse->flags |= FLAG_IS_FINISHING; + if (hse->state == HSES_NOT_FULL) { hse->state = HSES_FILLED; } + return hse->state == HSES_DONE ? HSER_FINISH_DONE : HSER_FINISH_MORE; +} + +static HSE_state st_step_search(heatshrink_encoder *hse) { + uint16_t window_length = get_input_buffer_size(hse); + uint16_t lookahead_sz = get_lookahead_size(hse); + uint16_t msi = hse->match_scan_index; + LOG("## step_search, scan @ +%d (%d/%d), input size %d\n", + msi, hse->input_size + msi, 2*window_length, hse->input_size); + + bool fin = is_finishing(hse); + if (msi > hse->input_size - (fin ? 1 : lookahead_sz)) { + /* Current search buffer is exhausted, copy it into the + * backlog and await more input. */ + LOG("-- end of search @ %d\n", msi); + return fin ? HSES_FLUSH_BITS : HSES_SAVE_BACKLOG; + } + + uint16_t input_offset = get_input_offset(hse); + uint16_t end = input_offset + msi; + uint16_t start = end - window_length; + + uint16_t max_possible = lookahead_sz; + if (hse->input_size - msi < lookahead_sz) { + max_possible = hse->input_size - msi; + } + + uint16_t match_length = 0; + uint16_t match_pos = find_longest_match(hse, + start, end, max_possible, &match_length); + + if (match_pos == MATCH_NOT_FOUND) { + LOG("ss Match not found\n"); + hse->match_scan_index++; + hse->match_length = 0; + return HSES_YIELD_TAG_BIT; + } else { + LOG("ss Found match of %d bytes at %d\n", match_length, match_pos); + hse->match_pos = match_pos; + hse->match_length = match_length; + ASSERT(match_pos <= 1 << HEATSHRINK_ENCODER_WINDOW_BITS(hse) /*window_length*/); + + return HSES_YIELD_TAG_BIT; + } +} + +static HSE_state st_yield_tag_bit(heatshrink_encoder *hse, + output_info *oi) { + if (can_take_byte(oi)) { + if (hse->match_length == 0) { + add_tag_bit(hse, oi, HEATSHRINK_LITERAL_MARKER); + return HSES_YIELD_LITERAL; + } else { + add_tag_bit(hse, oi, HEATSHRINK_BACKREF_MARKER); + hse->outgoing_bits = hse->match_pos - 1; + hse->outgoing_bits_count = HEATSHRINK_ENCODER_WINDOW_BITS(hse); + return HSES_YIELD_BR_INDEX; + } + } else { + return HSES_YIELD_TAG_BIT; /* output is full, continue */ + } +} + +static HSE_state st_yield_literal(heatshrink_encoder *hse, + output_info *oi) { + if (can_take_byte(oi)) { + push_literal_byte(hse, oi); + return HSES_SEARCH; + } else { + return HSES_YIELD_LITERAL; + } +} + +static HSE_state st_yield_br_index(heatshrink_encoder *hse, + output_info *oi) { + if (can_take_byte(oi)) { + LOG("-- yielding backref index %u\n", hse->match_pos); + if (push_outgoing_bits(hse, oi) > 0) { + return HSES_YIELD_BR_INDEX; /* continue */ + } else { + hse->outgoing_bits = hse->match_length - 1; + hse->outgoing_bits_count = HEATSHRINK_ENCODER_LOOKAHEAD_BITS(hse); + return HSES_YIELD_BR_LENGTH; /* done */ + } + } else { + return HSES_YIELD_BR_INDEX; /* continue */ + } +} + +static HSE_state st_yield_br_length(heatshrink_encoder *hse, + output_info *oi) { + if (can_take_byte(oi)) { + LOG("-- yielding backref length %u\n", hse->match_length); + if (push_outgoing_bits(hse, oi) > 0) { + return HSES_YIELD_BR_LENGTH; + } else { + hse->match_scan_index += hse->match_length; + hse->match_length = 0; + return HSES_SEARCH; + } + } else { + return HSES_YIELD_BR_LENGTH; + } +} + +static HSE_state st_save_backlog(heatshrink_encoder *hse) { + LOG("-- saving backlog\n"); + save_backlog(hse); + return HSES_NOT_FULL; +} + +static HSE_state st_flush_bit_buffer(heatshrink_encoder *hse, + output_info *oi) { + if (hse->bit_index == 0x80) { + LOG("-- done!\n"); + return HSES_DONE; + } else if (can_take_byte(oi)) { + LOG("-- flushing remaining byte (bit_index == 0x%02x)\n", hse->bit_index); + oi->buf[(*oi->output_size)++] = hse->current_byte; + LOG("-- done!\n"); + return HSES_DONE; + } else { + return HSES_FLUSH_BITS; + } +} + +static void add_tag_bit(heatshrink_encoder *hse, output_info *oi, uint8_t tag) { + LOG("-- adding tag bit: %d\n", tag); + push_bits(hse, 1, tag, oi); +} + +static uint16_t get_input_offset(heatshrink_encoder *hse) { + return get_input_buffer_size(hse); +} + +static uint16_t get_input_buffer_size(heatshrink_encoder *hse) { + return (1 << HEATSHRINK_ENCODER_WINDOW_BITS(hse)); + (void)hse; +} + +static uint16_t get_lookahead_size(heatshrink_encoder *hse) { + return (1 << HEATSHRINK_ENCODER_LOOKAHEAD_BITS(hse)); + (void)hse; +} + +static void do_indexing(heatshrink_encoder *hse) { +#if HEATSHRINK_USE_INDEX + /* Build an index array I that contains flattened linked lists + * for the previous instances of every byte in the buffer. + * + * For example, if buf[200] == 'x', then index[200] will either + * be an offset i such that buf[i] == 'x', or a negative offset + * to indicate end-of-list. This significantly speeds up matching, + * while only using sizeof(uint16_t)*sizeof(buffer) bytes of RAM. + * + * Future optimization options: + * 1. Since any negative value represents end-of-list, the other + * 15 bits could be used to improve the index dynamically. + * + * 2. Likewise, the last lookahead_sz bytes of the index will + * not be usable, so temporary data could be stored there to + * dynamically improve the index. + * */ + struct hs_index *hsi = HEATSHRINK_ENCODER_INDEX(hse); + int16_t last[256]; + memset(last, 0xFF, sizeof(last)); + + uint8_t * const data = hse->buffer; + int16_t * const index = hsi->index; + + const uint16_t input_offset = get_input_offset(hse); + const uint16_t end = input_offset + hse->input_size; + + for (uint16_t i=0; iflags & FLAG_IS_FINISHING; +} + +static int can_take_byte(output_info *oi) { + return *oi->output_size < oi->buf_size; +} + +/* Return the longest match for the bytes at buf[end:end+maxlen] between + * buf[start] and buf[end-1]. If no match is found, return -1. */ +static uint16_t find_longest_match(heatshrink_encoder *hse, uint16_t start, + uint16_t end, const uint16_t maxlen, uint16_t *match_length) { + LOG("-- scanning for match of buf[%u:%u] between buf[%u:%u] (max %u bytes)\n", + end, end + maxlen, start, end + maxlen - 1, maxlen); + uint8_t *buf = hse->buffer; + + uint16_t match_maxlen = 0; + uint16_t match_index = MATCH_NOT_FOUND; + + uint16_t len = 0; + uint8_t * const needlepoint = &buf[end]; +#if HEATSHRINK_USE_INDEX + struct hs_index *hsi = HEATSHRINK_ENCODER_INDEX(hse); + int16_t pos = hsi->index[end]; + + while (pos - (int16_t)start >= 0) { + uint8_t * const pospoint = &buf[pos]; + len = 0; + + /* Only check matches that will potentially beat the current maxlen. + * This is redundant with the index if match_maxlen is 0, but the + * added branch overhead to check if it == 0 seems to be worse. */ + if (pospoint[match_maxlen] != needlepoint[match_maxlen]) { + pos = hsi->index[pos]; + continue; + } + + for (len = 1; len < maxlen; len++) { + if (pospoint[len] != needlepoint[len]) break; + } + + if (len > match_maxlen) { + match_maxlen = len; + match_index = pos; + if (len == maxlen) { break; } /* won't find better */ + } + pos = hsi->index[pos]; + } +#else + for (int16_t pos=end - 1; pos - (int16_t)start >= 0; pos--) { + uint8_t * const pospoint = &buf[pos]; + if ((pospoint[match_maxlen] == needlepoint[match_maxlen]) + && (*pospoint == *needlepoint)) { + for (len=1; len cmp buf[%d] == 0x%02x against %02x (start %u)\n", + pos + len, pospoint[len], needlepoint[len], start); + } + if (pospoint[len] != needlepoint[len]) { break; } + } + if (len > match_maxlen) { + match_maxlen = len; + match_index = pos; + if (len == maxlen) { break; } /* don't keep searching */ + } + } + } +#endif + + const size_t break_even_point = + (1 + HEATSHRINK_ENCODER_WINDOW_BITS(hse) + + HEATSHRINK_ENCODER_LOOKAHEAD_BITS(hse)); + + /* Instead of comparing break_even_point against 8*match_maxlen, + * compare match_maxlen against break_even_point/8 to avoid + * overflow. Since MIN_WINDOW_BITS and MIN_LOOKAHEAD_BITS are 4 and + * 3, respectively, break_even_point/8 will always be at least 1. */ + if (match_maxlen > (break_even_point / 8)) { + LOG("-- best match: %u bytes at -%u\n", + match_maxlen, end - match_index); + *match_length = match_maxlen; + return end - match_index; + } + LOG("-- none found\n"); + return MATCH_NOT_FOUND; +} + +static uint8_t push_outgoing_bits(heatshrink_encoder *hse, output_info *oi) { + uint8_t count = 0; + uint8_t bits = 0; + if (hse->outgoing_bits_count > 8) { + count = 8; + bits = hse->outgoing_bits >> (hse->outgoing_bits_count - 8); + } else { + count = hse->outgoing_bits_count; + bits = hse->outgoing_bits; + } + + if (count > 0) { + LOG("-- pushing %d outgoing bits: 0x%02x\n", count, bits); + push_bits(hse, count, bits, oi); + hse->outgoing_bits_count -= count; + } + return count; +} + +/* Push COUNT (max 8) bits to the output buffer, which has room. + * Bytes are set from the lowest bits, up. */ +static void push_bits(heatshrink_encoder *hse, uint8_t count, uint8_t bits, + output_info *oi) { + ASSERT(count <= 8); + LOG("++ push_bits: %d bits, input of 0x%02x\n", count, bits); + + /* If adding a whole byte and at the start of a new output byte, + * just push it through whole and skip the bit IO loop. */ + if (count == 8 && hse->bit_index == 0x80) { + oi->buf[(*oi->output_size)++] = bits; + } else { + for (int i=count - 1; i>=0; i--) { + bool bit = bits & (1 << i); + if (bit) { hse->current_byte |= hse->bit_index; } + if (0) { + LOG(" -- setting bit %d at bit index 0x%02x, byte => 0x%02x\n", + bit ? 1 : 0, hse->bit_index, hse->current_byte); + } + hse->bit_index >>= 1; + if (hse->bit_index == 0x00) { + hse->bit_index = 0x80; + LOG(" > pushing byte 0x%02x\n", hse->current_byte); + oi->buf[(*oi->output_size)++] = hse->current_byte; + hse->current_byte = 0x00; + } + } + } +} + +static void push_literal_byte(heatshrink_encoder *hse, output_info *oi) { + uint16_t processed_offset = hse->match_scan_index - 1; + uint16_t input_offset = get_input_offset(hse) + processed_offset; + uint8_t c = hse->buffer[input_offset]; + LOG("-- yielded literal byte 0x%02x ('%c') from +%d\n", + c, isprint(c) ? c : '.', input_offset); + push_bits(hse, 8, c, oi); +} + +static void save_backlog(heatshrink_encoder *hse) { + size_t input_buf_sz = get_input_buffer_size(hse); + + uint16_t msi = hse->match_scan_index; + + /* Copy processed data to beginning of buffer, so it can be + * used for future matches. Don't bother checking whether the + * input is less than the maximum size, because if it isn't, + * we're done anyway. */ + uint16_t rem = input_buf_sz - msi; // unprocessed bytes + uint16_t shift_sz = input_buf_sz + rem; + + memmove(&hse->buffer[0], + &hse->buffer[input_buf_sz - rem], + shift_sz); + + hse->match_scan_index = 0; + hse->input_size -= input_buf_sz - rem; +} diff --git a/libraries/heatshrink-0.4.1/src/heatshrink_encoder.h b/libraries/heatshrink-0.4.1/src/heatshrink_encoder.h new file mode 100644 index 00000000..18c17731 --- /dev/null +++ b/libraries/heatshrink-0.4.1/src/heatshrink_encoder.h @@ -0,0 +1,109 @@ +#ifndef HEATSHRINK_ENCODER_H +#define HEATSHRINK_ENCODER_H + +#include +#include +#include "heatshrink_common.h" +#include "heatshrink_config.h" + +typedef enum { + HSER_SINK_OK, /* data sunk into input buffer */ + HSER_SINK_ERROR_NULL=-1, /* NULL argument */ + HSER_SINK_ERROR_MISUSE=-2, /* API misuse */ +} HSE_sink_res; + +typedef enum { + HSER_POLL_EMPTY, /* input exhausted */ + HSER_POLL_MORE, /* poll again for more output */ + HSER_POLL_ERROR_NULL=-1, /* NULL argument */ + HSER_POLL_ERROR_MISUSE=-2, /* API misuse */ +} HSE_poll_res; + +typedef enum { + HSER_FINISH_DONE, /* encoding is complete */ + HSER_FINISH_MORE, /* more output remaining; use poll */ + HSER_FINISH_ERROR_NULL=-1, /* NULL argument */ +} HSE_finish_res; + +#if HEATSHRINK_DYNAMIC_ALLOC +#define HEATSHRINK_ENCODER_WINDOW_BITS(HSE) \ + ((HSE)->window_sz2) +#define HEATSHRINK_ENCODER_LOOKAHEAD_BITS(HSE) \ + ((HSE)->lookahead_sz2) +#define HEATSHRINK_ENCODER_INDEX(HSE) \ + ((HSE)->search_index) +struct hs_index { + uint16_t size; + int16_t index[]; +}; +#else +#define HEATSHRINK_ENCODER_WINDOW_BITS(_) \ + (HEATSHRINK_STATIC_WINDOW_BITS) +#define HEATSHRINK_ENCODER_LOOKAHEAD_BITS(_) \ + (HEATSHRINK_STATIC_LOOKAHEAD_BITS) +#define HEATSHRINK_ENCODER_INDEX(HSE) \ + (&(HSE)->search_index) +struct hs_index { + uint16_t size; + int16_t index[2 << HEATSHRINK_STATIC_WINDOW_BITS]; +}; +#endif + +typedef struct { + uint16_t input_size; /* bytes in input buffer */ + uint16_t match_scan_index; + uint16_t match_length; + uint16_t match_pos; + uint16_t outgoing_bits; /* enqueued outgoing bits */ + uint8_t outgoing_bits_count; + uint8_t flags; + uint8_t state; /* current state machine node */ + uint8_t current_byte; /* current byte of output */ + uint8_t bit_index; /* current bit index */ +#if HEATSHRINK_DYNAMIC_ALLOC + uint8_t window_sz2; /* 2^n size of window */ + uint8_t lookahead_sz2; /* 2^n size of lookahead */ +#if HEATSHRINK_USE_INDEX + struct hs_index *search_index; +#endif + /* input buffer and / sliding window for expansion */ + uint8_t buffer[]; +#else + #if HEATSHRINK_USE_INDEX + struct hs_index search_index; + #endif + /* input buffer and / sliding window for expansion */ + uint8_t buffer[2 << HEATSHRINK_ENCODER_WINDOW_BITS(_)]; +#endif +} heatshrink_encoder; + +#if HEATSHRINK_DYNAMIC_ALLOC +/* Allocate a new encoder struct and its buffers. + * Returns NULL on error. */ +heatshrink_encoder *heatshrink_encoder_alloc(uint8_t window_sz2, + uint8_t lookahead_sz2); + +/* Free an encoder. */ +void heatshrink_encoder_free(heatshrink_encoder *hse); +#endif + +/* Reset an encoder. */ +void heatshrink_encoder_reset(heatshrink_encoder *hse); + +/* Sink up to SIZE bytes from IN_BUF into the encoder. + * INPUT_SIZE is set to the number of bytes actually sunk (in case a + * buffer was filled.). */ +HSE_sink_res heatshrink_encoder_sink(heatshrink_encoder *hse, + uint8_t *in_buf, size_t size, size_t *input_size); + +/* Poll for output from the encoder, copying at most OUT_BUF_SIZE bytes into + * OUT_BUF (setting *OUTPUT_SIZE to the actual amount copied). */ +HSE_poll_res heatshrink_encoder_poll(heatshrink_encoder *hse, + uint8_t *out_buf, size_t out_buf_size, size_t *output_size); + +/* Notify the encoder that the input stream is finished. + * If the return value is HSER_FINISH_MORE, there is still more output, so + * call heatshrink_encoder_poll and repeat. */ +HSE_finish_res heatshrink_encoder_finish(heatshrink_encoder *hse); + +#endif From d0318648c4afaceccf7b145cd0d0764988dc46f8 Mon Sep 17 00:00:00 2001 From: "Andrea C. G. Mennucci" Date: Wed, 12 Aug 2026 22:46:32 +0200 Subject: [PATCH 3/4] Fix heatshrink encoder finalization Apply atomicobject/heatshrink#87 (upstream commit 3b85e98) and add a regression test for finishing with no buffered input. --- libraries/heatshrink-0.4.1/README.ESP3D.md | 3 +++ .../heatshrink-0.4.1/src/heatshrink_encoder.c | 4 +++- .../test/encoder_finish_empty.c | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 libraries/heatshrink-0.4.1/test/encoder_finish_empty.c diff --git a/libraries/heatshrink-0.4.1/README.ESP3D.md b/libraries/heatshrink-0.4.1/README.ESP3D.md index ad947ce1..1c12d121 100644 --- a/libraries/heatshrink-0.4.1/README.ESP3D.md +++ b/libraries/heatshrink-0.4.1/README.ESP3D.md @@ -7,3 +7,6 @@ This directory contains the encoder and license from the upstream Only the encoder is required. Marlin provides the matching decoder and announces its window and lookahead parameters during Binary File Transfer capability negotiation. + +The encoder includes the finalization fix from upstream commit `3b85e98` +(atomicobject/heatshrink#87), applied separately from the pristine import. diff --git a/libraries/heatshrink-0.4.1/src/heatshrink_encoder.c b/libraries/heatshrink-0.4.1/src/heatshrink_encoder.c index edf4abeb..9bea0fc4 100644 --- a/libraries/heatshrink-0.4.1/src/heatshrink_encoder.c +++ b/libraries/heatshrink-0.4.1/src/heatshrink_encoder.c @@ -265,7 +265,9 @@ static HSE_state st_step_search(heatshrink_encoder *hse) { msi, hse->input_size + msi, 2*window_length, hse->input_size); bool fin = is_finishing(hse); - if (msi > hse->input_size - (fin ? 1 : lookahead_sz)) { + if (fin && hse->input_size == 0) { + return HSES_FLUSH_BITS; + } else if (msi > hse->input_size - (fin ? 1 : lookahead_sz)) { /* Current search buffer is exhausted, copy it into the * backlog and await more input. */ LOG("-- end of search @ %d\n", msi); diff --git a/libraries/heatshrink-0.4.1/test/encoder_finish_empty.c b/libraries/heatshrink-0.4.1/test/encoder_finish_empty.c new file mode 100644 index 00000000..1d91d781 --- /dev/null +++ b/libraries/heatshrink-0.4.1/test/encoder_finish_empty.c @@ -0,0 +1,21 @@ +#include +#include +#include + +#include "heatshrink_encoder.h" + +int main(void) { + heatshrink_encoder *encoder = heatshrink_encoder_alloc(8, 4); + assert(encoder != NULL); + + uint8_t output[8]; + size_t output_size = sizeof(output); + assert(heatshrink_encoder_finish(encoder) == HSER_FINISH_MORE); + assert(heatshrink_encoder_poll(encoder, output, sizeof(output), + &output_size) == HSER_POLL_EMPTY); + assert(output_size == 0); + assert(heatshrink_encoder_finish(encoder) == HSER_FINISH_DONE); + + heatshrink_encoder_free(encoder); + return 0; +} From 9fe57c2d35290c69d95ec75eec3b340d8be30026 Mon Sep 17 00:00:00 2001 From: "Andrea C. G. Mennucci" Date: Wed, 12 Aug 2026 23:08:19 +0200 Subject: [PATCH 4/4] Add Marlin binary file transfer service Add an asynchronous HTTP API for copying files from ESP storage to a Marlin SD card, with M115 capability checks, negotiated heatshrink compression, retries, cancellation, and exclusive printer-link ownership. --- docs/marlin_binary_file_transfer.md | 40 ++ esp3d/configuration.h | 8 +- esp3d/src/core/esp3d.cpp | 6 + esp3d/src/include/esp3d_config.h | 5 + esp3d/src/include/esp3d_sanity.h | 12 + .../http/handlers/handle-marlin-bft.cpp | 83 +++ esp3d/src/modules/http/http_server.cpp | 3 + esp3d/src/modules/http/http_server.h | 3 + .../modules/marlin_bft/marlin_bft_service.cpp | 639 ++++++++++++++++++ .../modules/marlin_bft/marlin_bft_service.h | 133 ++++ .../modules/usb-serial/usb_serial_service.cpp | 33 +- 11 files changed, 949 insertions(+), 16 deletions(-) create mode 100644 docs/marlin_binary_file_transfer.md create mode 100644 esp3d/src/modules/http/handlers/handle-marlin-bft.cpp create mode 100644 esp3d/src/modules/marlin_bft/marlin_bft_service.cpp create mode 100644 esp3d/src/modules/marlin_bft/marlin_bft_service.h diff --git a/docs/marlin_binary_file_transfer.md b/docs/marlin_binary_file_transfer.md new file mode 100644 index 00000000..b1785588 --- /dev/null +++ b/docs/marlin_binary_file_transfer.md @@ -0,0 +1,40 @@ +# Marlin Binary File Transfer + +Enable `MARLIN_BINARY_FILE_TRANSFER_FEATURE` to copy a file from ESP3D flash +or its local SD card to storage managed by Marlin. Marlin must advertise +`Cap:BINARY_FILE_TRANSFER:1` in its `M115` response. + +ESP3D negotiates the packet size and compression parameters. With +`compression=auto`, heatshrink is selected when Marlin offers it; otherwise +the file is sent uncompressed. While a transfer is active, ESP3D reserves the +printer connection and rejects other commands. + +## HTTP API + +Start a transfer with an authenticated POST request: + +```text +POST /printer-sd-transfer?action=start&source=/FS/model.gcode&destination=/model.gcode&compression=auto +``` + +Use `/FS/...` for ESP flash and `/SD/...` for the ESP's local SD card. Optional +parameters are `compression=auto|none|heatshrink` and `dummy=true|false`. + +Poll progress with: + +```text +GET /printer-sd-transfer?action=status +``` + +The JSON response includes `status`, `active`, source and wire byte counts, +`progress`, negotiated compression parameters, retries, elapsed time, and an +error message. Terminal states are `completed`, `cancelled`, and `failed`. + +Cancel an active transfer with: + +```text +POST /printer-sd-transfer?action=cancel +``` + +The WebUI should poll status until `active` becomes false, then refresh the +Marlin SD listing after a successful transfer. diff --git a/esp3d/configuration.h b/esp3d/configuration.h index 12948a5f..43cb2184 100644 --- a/esp3d/configuration.h +++ b/esp3d/configuration.h @@ -181,6 +181,12 @@ */ #define HTTP_FEATURE +/* Copy files from the ESP flash / local SD to a Marlin-controlled SD card + * using Marlin's Binary File Transfer protocol (M28 B1). + * Marlin must be compiled with BINARY_FILE_TRANSFER. + */ +//#define MARLIN_BINARY_FILE_TRANSFER_FEATURE + /* Use telnet server * Enable telnet light (raw tcp) communications */ @@ -705,4 +711,4 @@ #undef NOTIFICATION_FEATURE #endif -#endif //_CONFIGURATION_H \ No newline at end of file +#endif //_CONFIGURATION_H diff --git a/esp3d/src/core/esp3d.cpp b/esp3d/src/core/esp3d.cpp index ff0eff28..0a6374c9 100644 --- a/esp3d/src/core/esp3d.cpp +++ b/esp3d/src/core/esp3d.cpp @@ -62,6 +62,9 @@ #if defined(USB_SERIAL_FEATURE) #include "../modules/usb-serial/usb_serial_service.h" #endif // USB_SERIAL_FEATURE +#if defined(MARLIN_BINARY_FILE_TRANSFER_FEATURE) +#include "../modules/marlin_bft/marlin_bft_service.h" +#endif bool Esp3D::restart = false; @@ -193,6 +196,9 @@ void Esp3D::handle() { esp3d_serial_service.handle(); #endif // COMMUNICATION_PROTOCOL == RAW_SERIAL || COMMUNICATION_PROTOCOL == // MKS_SERIAL +#if defined(MARLIN_BINARY_FILE_TRANSFER_FEATURE) + marlin_bft_service.handle(); +#endif #if defined(ESP_SERIAL_BRIDGE_OUTPUT) serial_bridge_service.handle(); #endif // ESP_SERIAL_BRIDGE_OUTPUT diff --git a/esp3d/src/include/esp3d_config.h b/esp3d/src/include/esp3d_config.h index 1e7aa770..eb170d15 100644 --- a/esp3d/src/include/esp3d_config.h +++ b/esp3d/src/include/esp3d_config.h @@ -40,6 +40,11 @@ #endif #endif +#if defined(MARLIN_BINARY_FILE_TRANSFER_FEATURE) && \ + !defined(GLOBAL_FILESYSTEM_FEATURE) +#define GLOBAL_FILESYSTEM_FEATURE +#endif + #include "../core/esp3d_hal.h" #include "../core/esp3d_log.h" #include "../include/esp3d_pins.h" diff --git a/esp3d/src/include/esp3d_sanity.h b/esp3d/src/include/esp3d_sanity.h index 11af8b56..303613a9 100644 --- a/esp3d/src/include/esp3d_sanity.h +++ b/esp3d/src/include/esp3d_sanity.h @@ -81,6 +81,18 @@ #endif // defined(ESP_SERIAL_BRIDGE_OUTPUT) #endif // COMMUNICATION_PROTOCOL == MKS_SERIAL +#if defined(MARLIN_BINARY_FILE_TRANSFER_FEATURE) +#if COMMUNICATION_PROTOCOL != RAW_SERIAL +#error MARLIN_BINARY_FILE_TRANSFER_FEATURE requires RAW_SERIAL +#endif +#if !defined(FILESYSTEM_FEATURE) && !defined(SD_DEVICE) +#error MARLIN_BINARY_FILE_TRANSFER_FEATURE requires FILESYSTEM_FEATURE or SD_DEVICE +#endif +#if !defined(HTTP_FEATURE) +#error MARLIN_BINARY_FILE_TRANSFER_FEATURE requires HTTP_FEATURE +#endif +#endif // MARLIN_BINARY_FILE_TRANSFER_FEATURE + /************************** * USB-Serial * ***********************/ diff --git a/esp3d/src/modules/http/handlers/handle-marlin-bft.cpp b/esp3d/src/modules/http/handlers/handle-marlin-bft.cpp new file mode 100644 index 00000000..e7cf26d1 --- /dev/null +++ b/esp3d/src/modules/http/handlers/handle-marlin-bft.cpp @@ -0,0 +1,83 @@ +/* + handle-marlin-bft.cpp - Marlin Binary File Transfer HTTP API + + Copyright (c) 2026 ESP3D contributors + + This code is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. +*/ + +#include "../../../include/esp3d_config.h" + +#if defined(HTTP_FEATURE) && defined(MARLIN_BINARY_FILE_TRANSFER_FEATURE) + +#if defined(ARDUINO_ARCH_ESP32) +#include +#else +#include +#endif + +#include "../http_server.h" +#include "../../authentication/authentication_service.h" +#include "../../marlin_bft/marlin_bft_service.h" + +void HTTP_Server::handleMarlinBft() { + set_http_headers(); + if (AuthenticationService::getAuthenticatedLevel() == + ESP3DAuthenticationLevel::guest) { + _webserver->send(401, "application/json", + "{\"status\":\"error\",\"error\":\"Wrong authentication\"}"); + return; + } + + String action = _webserver->hasArg("action") + ? _webserver->arg("action") + : "status"; + action.toLowerCase(); + + if (action == "start") { + if (_webserver->method() != HTTP_POST) { + _webserver->send(405, "application/json", + "{\"status\":\"error\",\"error\":\"Use POST to start a transfer\"}"); + return; + } + if (!_webserver->hasArg("source") || + !_webserver->hasArg("destination")) { + _webserver->send(400, "application/json", + "{\"status\":\"error\",\"error\":\"source and destination are required\"}"); + return; + } + const String compression = _webserver->hasArg("compression") + ? _webserver->arg("compression") + : "auto"; + const bool dummy = _webserver->hasArg("dummy") && + _webserver->arg("dummy") == "true"; + const bool started = marlin_bft_service.start( + _webserver->arg("source").c_str(), + _webserver->arg("destination").c_str(), compression.c_str(), dummy); + _webserver->send(started ? 202 : 409, "application/json", + marlin_bft_service.statusJson()); + return; + } + if (action == "cancel") { + if (_webserver->method() != HTTP_POST) { + _webserver->send(405, "application/json", + "{\"status\":\"error\",\"error\":\"Use POST to cancel a transfer\"}"); + return; + } + marlin_bft_service.cancel(); + _webserver->send(202, "application/json", + marlin_bft_service.statusJson()); + return; + } + if (action != "status") { + _webserver->send(400, "application/json", + "{\"status\":\"error\",\"error\":\"Unknown action\"}"); + return; + } + _webserver->send(200, "application/json", marlin_bft_service.statusJson()); +} + +#endif // HTTP_FEATURE && MARLIN_BINARY_FILE_TRANSFER_FEATURE diff --git a/esp3d/src/modules/http/http_server.cpp b/esp3d/src/modules/http/http_server.cpp index 46e794e8..5bd46e6d 100644 --- a/esp3d/src/modules/http/http_server.cpp +++ b/esp3d/src/modules/http/http_server.cpp @@ -67,6 +67,9 @@ void HTTP_Server::init_handlers() { #ifdef FILESYSTEM_FEATURE _webserver->on("/files", HTTP_ANY, handleFSFileList, FSFileupload); #endif // FILESYSTEM_FEATURE +#ifdef MARLIN_BINARY_FILE_TRANSFER_FEATURE + _webserver->on("/printer-sd-transfer", HTTP_ANY, handleMarlinBft); +#endif // MARLIN_BINARY_FILE_TRANSFER_FEATURE #if COMMUNICATION_PROTOCOL == MKS_SERIAL // MKS_SERIAL _webserver->on("/upload", HTTP_ANY, handleMKSUpload, MKSFileupload); diff --git a/esp3d/src/modules/http/http_server.h b/esp3d/src/modules/http/http_server.h index c0115ccd..cd909adc 100644 --- a/esp3d/src/modules/http/http_server.h +++ b/esp3d/src/modules/http/http_server.h @@ -81,6 +81,9 @@ class HTTP_Server { static void FSFileupload(); static void handleFSFileList(); #endif // FILESYSTEM_FEATURE +#ifdef MARLIN_BINARY_FILE_TRANSFER_FEATURE + static void handleMarlinBft(); +#endif // MARLIN_BINARY_FILE_TRANSFER_FEATURE #ifdef WEB_UPDATE_FEATURE static void handleUpdate(); static void WebUpdateUpload(); diff --git a/esp3d/src/modules/marlin_bft/marlin_bft_service.cpp b/esp3d/src/modules/marlin_bft/marlin_bft_service.cpp new file mode 100644 index 00000000..058e2980 --- /dev/null +++ b/esp3d/src/modules/marlin_bft/marlin_bft_service.cpp @@ -0,0 +1,639 @@ +/* + marlin_bft_service.cpp - Marlin Binary File Transfer client + + Copyright (c) 2026 ESP3D contributors + + This code is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. +*/ + +#include "marlin_bft_service.h" + +#if defined(MARLIN_BINARY_FILE_TRANSFER_FEATURE) + +#include "../../core/esp3d_commands.h" +#include "../printer_link/printer_link_service.h" +#include "../serial/serial_service.h" +#if defined(USB_SERIAL_FEATURE) +#include "../usb-serial/usb_serial_service.h" +#endif + +MarlinBftService marlin_bft_service; + +namespace { +constexpr uint8_t PROTOCOL_CONTROL = 0; +constexpr uint8_t PROTOCOL_FILE_TRANSFER = 1; +constexpr uint8_t CONTROL_SYNC = 1; +constexpr uint8_t CONTROL_CLOSE = 2; +constexpr uint8_t FILE_QUERY = 0; +constexpr uint8_t FILE_OPEN = 1; +constexpr uint8_t FILE_CLOSE = 2; +constexpr uint8_t FILE_WRITE = 3; +constexpr uint8_t FILE_ABORT = 4; +constexpr uint16_t HEADER_TOKEN = 0xB5AD; +constexpr char PRINTER_LINK_OWNER[] = "binary-transfer"; +} + +bool MarlinBftService::ownsPrinterLink() const { return _linkCaptured; } + +bool MarlinBftService::start(const char *source, const char *destination, + const char *compression, bool dummy) { + if (ownsPrinterLink()) { + return false; + } + if (!source || !destination || !source[0] || !destination[0]) { + _error = "Source and destination are required"; + _state = State::Failed; + return false; + } + + String sourcePath(source), destinationPath(destination); + sourcePath.trim(); + destinationPath.trim(); + if (!sourcePath.length() || !destinationPath.length() || + destinationPath.indexOf("..") >= 0 || destinationPath.length() > 240 || + destinationPath.indexOf('\n') >= 0 || destinationPath.indexOf('\r') >= 0) { + _error = "Invalid destination path"; + _state = State::Failed; + return false; + } + + String mode = compression ? compression : "auto"; + mode.toLowerCase(); + if (mode == "auto") + _compressionMode = CompressionMode::Auto; + else if (mode == "none") + _compressionMode = CompressionMode::None; + else if (mode == "heatshrink") + _compressionMode = CompressionMode::Heatshrink; + else { + _error = "Invalid compression mode"; + _state = State::Failed; + return false; + } + + _sourceFs = ESP_GBFS::getFSType(sourcePath.c_str()); + if (_sourceFs != FS_FLASH && _sourceFs != FS_SD) { + _error = "Source must begin with /FS or /SD"; + _state = State::Failed; + return false; + } + if (!ESP_GBFS::accessFS(_sourceFs)) { + _error = "Source filesystem is busy or unavailable"; + _state = State::Failed; + return false; + } + _sourceFile = ESP_GBFS::open(sourcePath.c_str(), ESP_FILE_READ); + if (!_sourceFile || _sourceFile.isDirectory()) { + releaseSource(); + _error = "Cannot open source file"; + _state = State::Failed; + return false; + } + + _sourcePath = sourcePath; + _destination = destinationPath; + _sourceSize = _sourceFile.size(); + _sourceBytesRead = 0; + _wireBytesSent = 0; + _totalRetries = 0; + _inputSize = _inputOffset = 0; + _payloadSize = 0; + _sourceEof = false; + _encoderFinished = false; + _dummy = dummy; + _finalResult = FinalResult::Completed; + _binaryMode = false; + _cancelRequested = false; + _capabilitySeen = _capabilitySupported = _asciiOkReceived = false; + _enteredReceived = _syncReceived = _resendRequested = false; + _streamErrorReceived = _pftErrorReceived = false; + _ackReceived = _pftReceived = _pftSuccess = false; + _heatshrinkOffered = _compressionEnabled = false; + _windowBits = _lookaheadBits = 0; + _sync = _packetSync = _packetRetries = 0; + _serverPayloadSize = _packetSize = 0; + _lineSize = 0; + _error = ""; + _startedAt = millis(); + + if (!printer_link_service.acquire(PRINTER_LINK_OWNER, receivePrinterData, + this)) { + releaseSource(); + _error = "Printer link is already in use"; + _state = State::Failed; + return false; + } + _linkCaptured = true; + _state = State::CheckingCapabilities; + + static const uint8_t capabilityCommand[] = "M115\n"; + if (!writePrinter(capabilityCommand, sizeof(capabilityCommand) - 1)) { + fail("Cannot send M115"); + return false; + } + _deadline = millis() + RESPONSE_TIMEOUT_MS; + return true; +} + +void MarlinBftService::cancel() { + if (ownsPrinterLink()) _cancelRequested = true; +} + +void MarlinBftService::receivePrinterData(const uint8_t *data, size_t size, + void *context) { + if (context) + static_cast(context)->consumeRx(data, size); +} + +void MarlinBftService::consumeRx(const uint8_t *data, size_t size) { + if (!_linkCaptured) return; + for (size_t i = 0; i < size; ++i) { + const char c = static_cast(data[i]); + if (c == '\n' || c == '\r') { + if (_lineSize) { + _line[_lineSize] = '\0'; + processLine(_line); + _lineSize = 0; + } + } + else if (_lineSize + 1 < sizeof(_line)) { + _line[_lineSize++] = c; + } + else { + _lineSize = 0; + } + } +} + +void MarlinBftService::processLine(const char *line) { + if (!line || !line[0]) return; + + if (_state == State::CheckingCapabilities) { + const char *capability = strstr(line, "Cap:BINARY_FILE_TRANSFER:"); + if (capability) { + _capabilitySeen = true; + capability += strlen("Cap:BINARY_FILE_TRANSFER:"); + _capabilitySupported = capability[0] == '1'; + } + if (!strncmp(line, "ok", 2)) _asciiOkReceived = true; + return; + } + + if (_state == State::EnteringBinaryMode && + strstr(line, "Switching to Binary Protocol")) { + _enteredReceived = true; + return; + } + + if (_state == State::Syncing && line[0] == 's' && line[1] == 's') { + unsigned sync = 0, payload = 0; + if (sscanf(line + 2, "%u,%u", &sync, &payload) == 2 && payload > 0) { + _sync = static_cast(sync); + _serverPayloadSize = min(payload, static_cast(MAX_PAYLOAD_SIZE)); + _syncReceived = true; + } + return; + } + + if (line[0] == 'o' && line[1] == 'k') { + const unsigned received = strtoul(line + 2, nullptr, 10); + if (static_cast(received) == _packetSync) _ackReceived = true; + return; + } + if (line[0] == 'r' && line[1] == 's') { + const unsigned requested = strtoul(line + 2, nullptr, 10); + if (static_cast(requested) == _packetSync) + _resendRequested = true; + return; + } + if (!strncmp(line, "fe", 2)) { + _streamErrorReceived = true; + return; + } + if (!strncmp(line, "PFT:", 4)) { + _pftReceived = true; + _pftSuccess = strstr(line, "PFT:success") == line; + if (_state == State::Querying) { + _pftSuccess = strstr(line, "PFT:version:") == line; + const char *capability = strstr(line, ":compression:heatshrink,"); + if (capability) { + unsigned window = 0, lookahead = 0; + if (sscanf(capability, ":compression:heatshrink,%u,%u", &window, + &lookahead) == 2) { + _heatshrinkOffered = true; + _windowBits = static_cast(window); + _lookaheadBits = static_cast(lookahead); + } + } + } + else if (_state == State::Writing) { + _pftErrorReceived = true; + } + } +} + +void MarlinBftService::handle() { + if (!ownsPrinterLink()) return; + + if (_streamErrorReceived) { + _streamErrorReceived = false; + fail("Marlin reported a binary stream error"); + return; + } + if (_pftErrorReceived) { + _pftErrorReceived = false; + fail("Marlin reported a file write error"); + return; + } + + if (_state == State::CheckingCapabilities && _asciiOkReceived) { + if (!_capabilitySeen || !_capabilitySupported) { + fail("Marlin does not advertise BINARY_FILE_TRANSFER"); + return; + } + _asciiOkReceived = false; + _state = State::EnteringBinaryMode; + static const uint8_t enterCommand[] = "M28 B1\n"; + if (!writePrinter(enterCommand, sizeof(enterCommand) - 1)) { + fail("Cannot send M28 B1"); + return; + } + _deadline = millis() + RESPONSE_TIMEOUT_MS; + return; + } + if (_state == State::EnteringBinaryMode && _enteredReceived) { + _enteredReceived = false; + _binaryMode = true; + _state = State::Syncing; + sendPacket(PROTOCOL_CONTROL, CONTROL_SYNC, nullptr, 0); + return; + } + if (_state == State::Syncing && _syncReceived) { + _syncReceived = false; + _state = State::Querying; + sendPacket(PROTOCOL_FILE_TRANSFER, FILE_QUERY, nullptr, 0); + return; + } + if (_resendRequested) { + _resendRequested = false; + if (!resendPacket()) fail("Marlin requested too many retries"); + return; + } + + if (_cancelRequested && _state != State::Aborting && + _state != State::ClosingConnection) { + sendAbort(FinalResult::Cancelled, "Transfer cancelled"); + return; + } + + processResponses(); + if (!ownsPrinterLink()) return; + + if (static_cast(millis() - _deadline) >= 0) { + if (_state == State::CheckingCapabilities) { + fail("Marlin did not answer the BINARY_FILE_TRANSFER capability query"); + } + else if (_state == State::EnteringBinaryMode) { + fail("Marlin did not enter Binary File Transfer mode"); + } + else if (_ackReceived && (_state == State::Querying || + _state == State::Opening || + _state == State::ClosingFile || + _state == State::Aborting)) { + fail("Marlin operation response timeout"); + } + else if (!resendPacket()) { + fail("Printer response timeout"); + } + } +} + +void MarlinBftService::processResponses() { + if (_state == State::Syncing) return; + if (!_ackReceived) return; + + if (_state == State::Writing) { + _sync++; + _ackReceived = false; + sendNextPayload(); + return; + } + if (_state == State::ClosingConnection) { + _sync++; + finish(_finalResult); + return; + } + if (!_pftReceived) return; + + _sync++; + if (!_pftSuccess) { + fail("Marlin rejected the file-transfer request"); + return; + } + + if (_state == State::Querying) { + if (!prepareCompression()) return; + const size_t nameSize = _destination.length() + 1; + if (nameSize + 2 > _serverPayloadSize) { + fail("Destination name exceeds Marlin payload size"); + return; + } + _payload[0] = _dummy ? 1 : 0; + _payload[1] = _compressionEnabled ? 1 : 0; + memcpy(&_payload[2], _destination.c_str(), nameSize); + _state = State::Opening; + sendPacket(PROTOCOL_FILE_TRANSFER, FILE_OPEN, _payload, nameSize + 2); + } + else if (_state == State::Opening) { + sendNextPayload(); + } + else if (_state == State::ClosingFile) { + sendConnectionClose(); + } + else if (_state == State::Aborting) { + sendConnectionClose(); + } +} + +bool MarlinBftService::prepareCompression() { + _compressionEnabled = _compressionMode != CompressionMode::None && + _heatshrinkOffered; + if (_compressionMode == CompressionMode::Heatshrink && + !_heatshrinkOffered) { + fail("Marlin does not offer heatshrink compression"); + return false; + } + if (_compressionEnabled) { + if (_windowBits < 4 || _windowBits > 15 || _lookaheadBits < 3 || + _lookaheadBits >= _windowBits) { + fail("Marlin announced invalid heatshrink parameters"); + return false; + } + _encoder = heatshrink_encoder_alloc(_windowBits, _lookaheadBits); + if (!_encoder) { + fail("Cannot allocate heatshrink encoder"); + return false; + } + heatshrink_encoder_reset(_encoder); + } + return true; +} + +bool MarlinBftService::prepareNextPayload() { + _payloadSize = 0; + if (!_compressionEnabled) { + _payloadSize = _sourceFile.read(_payload, _serverPayloadSize); + _sourceBytesRead += _payloadSize; + return _payloadSize != 0; + } + + size_t guard = 0; + while (_payloadSize < _serverPayloadSize && guard++ < 4096) { + size_t produced = 0; + const HSE_poll_res pollResult = heatshrink_encoder_poll( + _encoder, &_payload[_payloadSize], _serverPayloadSize - _payloadSize, + &produced); + _payloadSize += produced; + if (pollResult == HSER_POLL_MORE || _payloadSize == _serverPayloadSize) + continue; + + if (_encoderFinished) break; + + if (_inputOffset < _inputSize) { + size_t consumed = 0; + const HSE_sink_res sinkResult = heatshrink_encoder_sink( + _encoder, &_input[_inputOffset], _inputSize - _inputOffset, + &consumed); + if (sinkResult < 0) { + fail("heatshrink rejected input"); + return false; + } + _inputOffset += consumed; + continue; + } + + if (!_sourceEof) { + _inputSize = _sourceFile.read(_input, sizeof(_input)); + _inputOffset = 0; + _sourceBytesRead += _inputSize; + _sourceEof = _inputSize == 0; + if (!_sourceEof) continue; + } + + const HSE_finish_res finishResult = heatshrink_encoder_finish(_encoder); + if (finishResult < 0) { + fail("heatshrink finalization failed"); + return false; + } + _encoderFinished = finishResult == HSER_FINISH_DONE; + if (_encoderFinished) break; + } + if (guard >= 4096) { + fail("heatshrink encoder made no progress"); + return false; + } + return _payloadSize != 0; +} + +bool MarlinBftService::sendNextPayload() { + if (!prepareNextPayload()) { + if (_state == State::Failed || _state == State::Aborting) return false; + _state = State::ClosingFile; + return sendPacket(PROTOCOL_FILE_TRANSFER, FILE_CLOSE, nullptr, 0); + } + _state = State::Writing; + return sendPacket(PROTOCOL_FILE_TRANSFER, FILE_WRITE, _payload, _payloadSize); +} + +uint16_t MarlinBftService::checksum(uint16_t value, uint8_t byte) { + const uint16_t low = ((value & 0xFF) + byte) % 255; + return ((((value >> 8) + low) % 255) << 8) | low; +} + +bool MarlinBftService::sendPacket(uint8_t protocol, uint8_t type, + const uint8_t *payload, + size_t payloadSize) { + if (payloadSize > MAX_PAYLOAD_SIZE) { + fail("Binary packet is too large"); + return false; + } + _packet[0] = HEADER_TOKEN & 0xFF; + _packet[1] = HEADER_TOKEN >> 8; + _packet[2] = _sync; + _packet[3] = (protocol << 4) | (type & 0x0F); + _packet[4] = payloadSize & 0xFF; + _packet[5] = (payloadSize >> 8) & 0xFF; + + uint16_t sum = 0; + for (size_t i = 2; i < 6; ++i) sum = checksum(sum, _packet[i]); + const uint16_t headerSum = sum; + _packet[6] = headerSum & 0xFF; + _packet[7] = headerSum >> 8; + sum = checksum(sum, _packet[6]); + sum = checksum(sum, _packet[7]); + if (payloadSize) { + memcpy(&_packet[8], payload, payloadSize); + for (size_t i = 0; i < payloadSize; ++i) + sum = checksum(sum, payload[i]); + } + if (payloadSize) { + _packet[8 + payloadSize] = sum & 0xFF; + _packet[9 + payloadSize] = sum >> 8; + } + _packetSize = payloadSize ? payloadSize + 10 : 8; + _packetSync = _sync; + _packetRetries = 0; + _ackReceived = _pftReceived = _pftSuccess = false; + if (!writePrinter(_packet, _packetSize)) { + fail("Cannot write binary packet"); + return false; + } + _wireBytesSent += _packetSize; + _deadline = millis() + RESPONSE_TIMEOUT_MS; + return true; +} + +bool MarlinBftService::resendPacket() { + if (!_packetSize || _packetRetries >= MAX_RETRIES) return false; + ++_packetRetries; + ++_totalRetries; + _ackReceived = false; + if (!writePrinter(_packet, _packetSize)) return false; + _wireBytesSent += _packetSize; + _deadline = millis() + RESPONSE_TIMEOUT_MS; + return true; +} + +void MarlinBftService::sendAbort(FinalResult result, const char *reason) { + _finalResult = result; + if (reason) _error = reason; + _cancelRequested = false; + if (!_binaryMode) { + finish(result); + return; + } + _state = State::Aborting; + sendPacket(PROTOCOL_FILE_TRANSFER, FILE_ABORT, nullptr, 0); +} + +void MarlinBftService::sendConnectionClose() { + _state = State::ClosingConnection; + sendPacket(PROTOCOL_CONTROL, CONTROL_CLOSE, nullptr, 0); +} + +void MarlinBftService::fail(const char *reason) { + if (reason) _error = reason; + if (_binaryMode && _state != State::Aborting && + _state != State::ClosingConnection) { + sendAbort(FinalResult::Failed, reason); + } + else { + finish(FinalResult::Failed); + } +} + +void MarlinBftService::finish(FinalResult result) { + _binaryMode = false; + _cancelRequested = false; + if (_encoder) { + heatshrink_encoder_free(_encoder); + _encoder = nullptr; + } + releaseSource(); + if (result == FinalResult::Completed) { + _error = ""; + _state = State::Completed; + } + else if (result == FinalResult::Cancelled) { + _state = State::Cancelled; + } + else { + _state = State::Failed; + } + if (_linkCaptured) { + printer_link_service.release(PRINTER_LINK_OWNER); + _linkCaptured = false; + } +} + +void MarlinBftService::releaseSource() { + if (_sourceFile) _sourceFile.close(); + if (_sourceFs != FS_UNKNOWN) ESP_GBFS::releaseFS(_sourceFs); + _sourceFs = FS_UNKNOWN; +} + +bool MarlinBftService::writePrinter(const uint8_t *data, size_t size) { + const ESP3DClientType output = esp3d_commands.getOutputClient(); +#if defined(USB_SERIAL_FEATURE) + if (output == ESP3DClientType::usb_serial) + return esp3d_usb_serial_service.writeBytes(data, size) == size; +#endif + if (output == ESP3DClientType::serial) + return esp3d_serial_service.writeBytes(data, size) == size; + return false; +} + +const char *MarlinBftService::stateName(State state) { + switch (state) { + case State::Idle: return "idle"; + case State::CheckingCapabilities: return "checking_capabilities"; + case State::EnteringBinaryMode: return "entering"; + case State::Syncing: return "syncing"; + case State::Querying: return "querying"; + case State::Opening: return "opening"; + case State::Writing: return "writing"; + case State::ClosingFile: return "closing_file"; + case State::Aborting: return "aborting"; + case State::ClosingConnection: return "closing_connection"; + case State::Completed: return "completed"; + case State::Cancelled: return "cancelled"; + case State::Failed: return "failed"; + } + return "unknown"; +} + +String MarlinBftService::jsonEscape(const String &value) { + String escaped; + escaped.reserve(value.length() + 8); + for (size_t i = 0; i < value.length(); ++i) { + const char c = value[i]; + if (c == '\\' || c == '"') escaped += '\\'; + if (c == '\n') escaped += "\\n"; + else if (c == '\r') escaped += "\\r"; + else escaped += c; + } + return escaped; +} + +String MarlinBftService::statusJson() const { + const bool active = ownsPrinterLink(); + const unsigned progress = _sourceSize + ? static_cast((100ULL * _sourceBytesRead) / _sourceSize) + : (active ? 0 : 100); + String json; + json.reserve(384); + json = "{\"status\":\""; + json += stateName(_state); + json += "\",\"active\":"; + json += active ? "true" : "false"; + json += ",\"source\":\"" + jsonEscape(_sourcePath); + json += "\",\"destination\":\"" + jsonEscape(_destination); + json += "\",\"source_size\":" + String(_sourceSize); + json += ",\"source_bytes_read\":" + String(_sourceBytesRead); + json += ",\"wire_bytes_sent\":" + String(_wireBytesSent); + json += ",\"progress\":" + String(progress); + json += ",\"compression\":\""; + json += _compressionEnabled ? "heatshrink" : "none"; + json += "\",\"window_bits\":" + String(_windowBits); + json += ",\"lookahead_bits\":" + String(_lookaheadBits); + json += ",\"retries\":" + String(_totalRetries); + const uint32_t elapsed = _startedAt ? millis() - _startedAt : 0; + json += ",\"elapsed_ms\":" + String(elapsed); + json += ",\"error\":\"" + jsonEscape(_error) + "\"}"; + return json; +} + +#endif // MARLIN_BINARY_FILE_TRANSFER_FEATURE diff --git a/esp3d/src/modules/marlin_bft/marlin_bft_service.h b/esp3d/src/modules/marlin_bft/marlin_bft_service.h new file mode 100644 index 00000000..a1f99aa8 --- /dev/null +++ b/esp3d/src/modules/marlin_bft/marlin_bft_service.h @@ -0,0 +1,133 @@ +/* + marlin_bft_service.h - Marlin Binary File Transfer client + + Copyright (c) 2026 ESP3D contributors + + This code is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. +*/ + +#pragma once + +#include "../../include/esp3d_config.h" + +#if defined(MARLIN_BINARY_FILE_TRANSFER_FEATURE) + +#include +extern "C" { +#include +} + +#include "../filesystem/esp_globalFS.h" + +class MarlinBftService final { + public: + enum class State : uint8_t { + Idle, + CheckingCapabilities, + EnteringBinaryMode, + Syncing, + Querying, + Opening, + Writing, + ClosingFile, + Aborting, + ClosingConnection, + Completed, + Cancelled, + Failed + }; + + bool start(const char *source, const char *destination, + const char *compression = "auto", bool dummy = false); + void cancel(); + void handle(); + bool ownsPrinterLink() const; + String statusJson() const; + + private: + enum class CompressionMode : uint8_t { Auto, None, Heatshrink }; + enum class FinalResult : uint8_t { Completed, Cancelled, Failed }; + + static constexpr size_t MAX_PAYLOAD_SIZE = 512; + static constexpr size_t MAX_PACKET_SIZE = MAX_PAYLOAD_SIZE + 10; + static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2500; + static constexpr uint8_t MAX_RETRIES = 5; + + bool writePrinter(const uint8_t *data, size_t size); + static void receivePrinterData(const uint8_t *data, size_t size, + void *context); + void consumeRx(const uint8_t *data, size_t size); + bool sendPacket(uint8_t protocol, uint8_t type, const uint8_t *payload, + size_t payloadSize); + bool resendPacket(); + void processLine(const char *line); + void processResponses(); + bool prepareCompression(); + bool prepareNextPayload(); + bool sendNextPayload(); + void sendAbort(FinalResult result, const char *reason); + void sendConnectionClose(); + void finish(FinalResult result); + void fail(const char *reason); + void releaseSource(); + static uint16_t checksum(uint16_t value, uint8_t byte); + static const char *stateName(State state); + static String jsonEscape(const String &value); + + State _state = State::Idle; + FinalResult _finalResult = FinalResult::Completed; + CompressionMode _compressionMode = CompressionMode::Auto; + ESP_GBFile _sourceFile; + uint8_t _sourceFs = FS_UNKNOWN; + String _sourcePath; + String _destination; + String _error; + bool _dummy = false; + bool _linkCaptured = false; + bool _binaryMode = false; + bool _cancelRequested = false; + bool _capabilitySeen = false; + bool _capabilitySupported = false; + bool _asciiOkReceived = false; + bool _enteredReceived = false; + bool _syncReceived = false; + bool _resendRequested = false; + bool _streamErrorReceived = false; + bool _pftErrorReceived = false; + bool _ackReceived = false; + bool _pftReceived = false; + bool _pftSuccess = false; + bool _heatshrinkOffered = false; + bool _compressionEnabled = false; + bool _sourceEof = false; + bool _encoderFinished = false; + uint8_t _windowBits = 0; + uint8_t _lookaheadBits = 0; + uint8_t _sync = 0; + uint8_t _packetSync = 0; + uint8_t _packetRetries = 0; + uint32_t _totalRetries = 0; + size_t _serverPayloadSize = 0; + size_t _packetSize = 0; + size_t _payloadSize = 0; + size_t _inputSize = 0; + size_t _inputOffset = 0; + size_t _sourceSize = 0; + size_t _sourceBytesRead = 0; + size_t _wireBytesSent = 0; + uint32_t _deadline = 0; + uint32_t _startedAt = 0; + uint8_t _packet[MAX_PACKET_SIZE] = {}; + uint8_t _payload[MAX_PAYLOAD_SIZE] = {}; + uint8_t _input[512] = {}; + char _line[256] = {}; + size_t _lineSize = 0; + heatshrink_encoder *_encoder = nullptr; +}; + +extern MarlinBftService marlin_bft_service; + +#endif // MARLIN_BINARY_FILE_TRANSFER_FEATURE diff --git a/esp3d/src/modules/usb-serial/usb_serial_service.cpp b/esp3d/src/modules/usb-serial/usb_serial_service.cpp index a396819a..8fa83c9f 100644 --- a/esp3d/src/modules/usb-serial/usb_serial_service.cpp +++ b/esp3d/src/modules/usb-serial/usb_serial_service.cpp @@ -441,24 +441,27 @@ size_t ESP3DUsbSerialService::writeBytes(const uint8_t *buffer, size_t size) { esp3d_log_e("USB Serial not started or not connected"); return 0; } - esp3d_log("writeBytes %d : %s", size, (const char *)buffer); - if (_vcp_ptr && _vcp_ptr->tx_blocking((uint8_t *)buffer, size) == ESP_OK) { - if (!(_vcp_ptr && _vcp_ptr->set_control_line_state(true, true) == ESP_OK)) { - esp3d_log_e("Failed to set control line state"); - return 0; - - esp3d_log_e("Failed to send message"); - return 0; - } - return size; - } + esp3d_log("writeBytes %d", size); if (!_vcp_ptr) { esp3d_log_e("_vcp_ptr is null"); - } else { - esp3d_log_e("tx_blocking failed"); + return 0; } - esp3d_log_e("Failed to send message"); - return 0; + if (_vcp_ptr->set_control_line_state(true, true) != ESP_OK) { + esp3d_log_e("Failed to set control line state"); + return 0; + } + + size_t sent = 0; + while (sent < size) { + const size_t chunk = + min(size - sent, static_cast(ESP3D_USB_SERIAL_TX_BUFFER_SIZE)); + if (_vcp_ptr->tx_blocking((uint8_t *)&buffer[sent], chunk) != ESP_OK) { + esp3d_log_e("tx_blocking failed after %d bytes", sent); + return sent; + } + sent += chunk; + } + return sent; } size_t ESP3DUsbSerialService::readBytes(uint8_t *sbuf, size_t len) {