├── .github └── workflows │ └── platformio.yml ├── .gitignore ├── .travis.yml.disabled ├── ESP8266WirelessPrintAsync ├── CommandQueue.cpp ├── CommandQueue.h ├── ESP8266WirelessPrintAsync.ino ├── FileWrapper.cpp ├── FileWrapper.h ├── StorageFS.cpp └── StorageFS.h ├── README.md ├── WirelessPrinting.png └── platformio.ini /.github/workflows/platformio.yml: -------------------------------------------------------------------------------- 1 | name: PlatformIO 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | PlatformIO: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up Python 13 | uses: actions/setup-python@v1 14 | - name: Install dependencies 15 | run: | 16 | sudo apt-get install rename 17 | python -m pip install --upgrade pip 18 | pip install -U platformio==5.2.5 19 | - name: Run PlatformIO 20 | run: | 21 | set -x 22 | set -e 23 | VERSION=$(git rev-parse --short HEAD) 24 | HERE=$(readlink -f .) 25 | sed -i -e 's|#define SKETCH_VERSION ".*"|#define SKETCH_VERSION "'$VERSION'"|' $PWD/ESP8266WirelessPrintAsync/ESP8266WirelessPrintAsync.ino 26 | platformio run 27 | BOARD=$(echo $BD | cut -d ":" -f 3) 28 | pushd .pio/build 29 | for board_dir in */; do 30 | pushd "$board_dir" 31 | board=$(echo "$board_dir" | tr -d '/') 32 | rename "s|firmware|WirelessPrinting_${board}_${VERSION}|" firmware.* 33 | popd 34 | done 35 | popd 36 | mkdir WirelessPrinting 37 | find . -type f -name 'WirelessPrinting_*.bin' -exec cp {} ./WirelessPrinting/ \; 38 | ( cd ./WirelessPrinting ; zip -r ../WirelessPrinting_$GITHUB_RUN_NUMBER_$(date +%Y-%m-%d)-$(git rev-parse --short HEAD).zip * ) 39 | echo "artifactName=WirelessPrinting_$GITHUB_RUN_NUMBER_$(date +%Y-%m-%d)-$(git rev-parse --short HEAD)" >> $GITHUB_ENV 40 | - uses: actions/upload-artifact@v3 41 | with: 42 | name: ${{ env.artifactName }} # Exported above 43 | path: ./WirelessPrinting/* 44 | - name: Upload to GitHub Releases (only when building from main branch) 45 | if: ${{ github.ref == 'refs/heads/main' }} 46 | run: | 47 | set -ex 48 | wget -c https://github.com/probonopd/uploadtool/raw/master/upload.sh 49 | bash ./upload.sh ./WirelessPrinting*.zip 50 | 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Private information 2 | private.h 3 | .vscode 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | 58 | # Sphinx documentation 59 | docs/_build/ 60 | 61 | # PyBuilder 62 | target/ 63 | 64 | #Ipython Notebook 65 | .ipynb_checkpoints 66 | 67 | # PlatformIO 68 | .pio/ 69 | -------------------------------------------------------------------------------- /.travis.yml.disabled: -------------------------------------------------------------------------------- 1 | language: c 2 | 3 | install: 4 | - sudo apt-get update && sudo apt-get install -y --no-install-recommends python3-minimal python3-pip python3-setuptools 5 | - pip3 install platformio==4.2.1 6 | 7 | script: 8 | - VERSION=$(git rev-parse --short HEAD) 9 | - HERE=$(readlink -f .) 10 | - sed -i -e 's|#define SKETCH_VERSION ".*"|#define SKETCH_VERSION "'$VERSION'"|' $PWD/ESP8266WirelessPrintAsync/ESP8266WirelessPrintAsync.ino 11 | - platformio run 12 | - BOARD=$(echo $BD | cut -d ":" -f 3) 13 | - |2 14 | pushd .pio/build 15 | for board_dir in */; do 16 | pushd "$board_dir" 17 | board=$(echo "$board_dir" | tr -d '/') 18 | rename "s|firmware|ESP8266WirelessPrintAsync_${board}_${VERSION}|" firmware.* 19 | popd 20 | done 21 | popd 22 | 23 | after_success: 24 | - wget -c https://github.com/probonopd/uploadtool/raw/master/upload.sh 25 | - bash ./upload.sh .pio/build/*/*WirelessPrint*.bin 26 | 27 | branches: 28 | except: 29 | - # Do not build tags that we create when we upload to GitHub Releases 30 | - /^(?i:continuous)$/ 31 | -------------------------------------------------------------------------------- /ESP8266WirelessPrintAsync/CommandQueue.cpp: -------------------------------------------------------------------------------- 1 | #include "CommandQueue.h" 2 | 3 | CommandQueue commandQueue; //FIFO Queue 4 | 5 | int CommandQueue::head = 0; 6 | int CommandQueue::sendTail = 0; 7 | int CommandQueue::tail = 0; 8 | String CommandQueue::commandBuffer[COMMAND_BUFFER_SIZE]; 9 | 10 | int CommandQueue::getFreeSlots() { 11 | int freeSlots = COMMAND_BUFFER_SIZE - 1; 12 | 13 | int next = tail; 14 | while (next != head) { 15 | --freeSlots; 16 | next = nextBufferSlot(next); 17 | } 18 | 19 | return freeSlots; 20 | } 21 | 22 | // Tries to Add a command to the queue, returns true if possible 23 | bool CommandQueue::push(const String command) { 24 | int next = nextBufferSlot(head); 25 | if (next == tail || command == "") 26 | return false; 27 | 28 | commandBuffer[head] = command; 29 | head = next; 30 | 31 | return true; 32 | } 33 | 34 | // Returns the next command to be sent, and advances to the next 35 | String CommandQueue::popSend() { 36 | if (sendTail == head) 37 | return String(); 38 | 39 | const String command = commandBuffer[sendTail]; 40 | sendTail = nextBufferSlot(sendTail); 41 | 42 | return command; 43 | } 44 | 45 | // Returns the last command sent if it was received by the printer, otherwise returns empty 46 | String CommandQueue::popAcknowledge() { 47 | if (isAckEmpty()) 48 | return String(); 49 | 50 | const String command = commandBuffer[tail]; 51 | tail = nextBufferSlot(tail); 52 | 53 | return command; 54 | } 55 | -------------------------------------------------------------------------------- /ESP8266WirelessPrintAsync/CommandQueue.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #define COMMAND_BUFFER_SIZE 16 4 | 5 | #include 6 | 7 | class CommandQueue { 8 | private: 9 | static int head, sendTail, tail; 10 | static String commandBuffer[COMMAND_BUFFER_SIZE]; 11 | 12 | // Returns the next buffer slot (after index slot) if it's in between the size of the buffer 13 | static inline int nextBufferSlot(int index) { 14 | int next = index + 1; 15 | 16 | return next >= COMMAND_BUFFER_SIZE ? 0 : next; 17 | } 18 | 19 | public: 20 | // Check if buffer is empty 21 | static inline bool isEmpty() { 22 | return head == tail; 23 | } 24 | 25 | // Returns true if the command to be sent was the last sent (so there is no pending response) 26 | static inline bool isAckEmpty() { 27 | return tail == sendTail; 28 | } 29 | 30 | static int getFreeSlots(); 31 | 32 | static inline void clear() { 33 | head = sendTail = tail; 34 | } 35 | 36 | static bool push(const String command); 37 | 38 | // If there is a command pending to be sent returns it 39 | inline static String peekSend() { 40 | return (sendTail == head) ? String() : commandBuffer[sendTail]; 41 | } 42 | 43 | static String popSend(); 44 | static String popAcknowledge(); 45 | }; 46 | 47 | extern CommandQueue commandQueue; 48 | -------------------------------------------------------------------------------- /ESP8266WirelessPrintAsync/ESP8266WirelessPrintAsync.ino: -------------------------------------------------------------------------------- 1 | // Required: https://github.com/greiman/SdFat 2 | 3 | #include 4 | #include 5 | #if defined(ESP8266) 6 | #include // https://github.com/esp8266/Arduino/tree/master/libraries/ESP8266mDNS 7 | #elif defined(ESP32) 8 | #include 9 | #include 10 | #include 11 | #endif 12 | #include // https://github.com/bblanchon/ArduinoJson (for implementing a subset of the OctoPrint API) 13 | #include 14 | #include "StorageFS.h" 15 | #include // https://github.com/me-no-dev/ESPAsyncWebServer 16 | #include // https://github.com/alanswx/ESPAsyncWiFiManager/ 17 | #include // https://github.com/ayushsharma82/AsyncElegantOTA 18 | #include 19 | 20 | #include "CommandQueue.h" 21 | 22 | #include 23 | 24 | 25 | const uint16_t PixelCount = 20; // this example assumes 4 pixels, making it smaller will cause a failure 26 | const uint8_t PixelPin = 2; // make sure to set this to the correct pin, ignored for ESP8266 (there it is GPIO2 = D4) 27 | #define colorSaturation 255 28 | RgbColor red(colorSaturation, 0, 0); 29 | RgbColor green(0, colorSaturation, 0); 30 | RgbColor blue(0, 0, colorSaturation); 31 | RgbColor white(colorSaturation); 32 | RgbColor black(0); 33 | 34 | #if defined(ESP8266) 35 | NeoPixelBus strip(PixelCount); // ESP8266 always uses GPIO2 = D4 36 | #elif defined(ESP32) 37 | NeoPixelBus strip(PixelCount, PixelPin); 38 | #endif 39 | 40 | // On ESP8266 use the normal Serial() for now, but name it PrinterSerial for compatibility with ESP32 41 | // On ESP32, use Serial1 (rather than the normal Serial0 which prints stuff during boot that confuses the printer) 42 | #ifdef ESP8266 43 | #define PrinterSerial Serial 44 | #endif 45 | #ifdef ESP32 46 | HardwareSerial PrinterSerial(1); 47 | #endif 48 | 49 | WiFiServer telnetServer(23); 50 | WiFiClient serverClient; 51 | 52 | AsyncWebServer server(80); 53 | DNSServer dns; 54 | 55 | // Configurable parameters 56 | #define SKETCH_VERSION "2.x-localbuild" // Gets inserted at build time by .travis.yml 57 | #define USE_FAST_SD // Use Default fast SD clock, comment if your SD is an old or slow one. 58 | #define OTA_UPDATES // Enable OTA firmware updates, comment if you don't want it (OTA may lead to security issues because someone may load any code on device) 59 | //#define OTA_PASSWORD "" // Uncomment to protect OTA updates and assign a password (inside "") 60 | #define MAX_SUPPORTED_EXTRUDERS 6 // Number of supported extruder 61 | #define REPEAT_M115_TIMES 1 // M115 retries with same baud (MAX 255) 62 | 63 | #define PRINTER_RX_BUFFER_SIZE 0 // This is printer firmware 'RX_BUFFER_SIZE'. If such parameter is unknown please use 0 64 | #define TEMPERATURE_REPORT_INTERVAL 2 // Ask the printer for its temperatures status every 2 seconds 65 | #define KEEPALIVE_INTERVAL 2500 // Marlin defaults to 2 seconds, get a little of margin 66 | const uint32_t serialBauds[] = { 115200, 250000, 57600 }; // Marlin valid bauds (removed very low bauds; roughly ordered by popularity to speed things up) 67 | 68 | #define API_VERSION "0.1" 69 | #define VERSION "1.3.10" 70 | 71 | // The sketch on the ESP 72 | bool ESPrestartRequired; // Set this flag in the callbacks to restart ESP 73 | 74 | // Information from M115 75 | String fwMachineType = "Unknown"; 76 | uint8_t fwExtruders = 1; 77 | bool fwAutoreportTempCap, fwProgressCap, fwBuildPercentCap; 78 | 79 | // Printer status 80 | bool printerConnected, 81 | startPrint, 82 | isPrinting, 83 | printPause, 84 | restartPrint, 85 | cancelPrint, 86 | autoreportTempEnabled; 87 | 88 | uint32_t printStartTime; 89 | float printCompletion; 90 | 91 | // Serial communication 92 | String lastCommandSent, lastReceivedResponse; 93 | uint32_t lastPrintedLine; 94 | 95 | uint8_t serialBaudIndex; 96 | uint16_t printerUsedBuffer; 97 | uint32_t serialReceiveTimeoutTimer; 98 | 99 | // Uploaded file information 100 | String uploadedFullname; 101 | size_t uploadedFileSize, filePos; 102 | uint32_t uploadedFileDate = 1378847754; 103 | 104 | // Temperature for printer status reporting 105 | #define TEMP_COMMAND "M105" 106 | #define AUTOTEMP_COMMAND "M155 S" 107 | 108 | struct Temperature { 109 | String actual, target; 110 | }; 111 | 112 | uint32_t temperatureTimer; 113 | 114 | Temperature toolTemperature[MAX_SUPPORTED_EXTRUDERS]; 115 | Temperature bedTemperature; 116 | 117 | 118 | // https://forum.arduino.cc/index.php?topic=228884.msg2670971#msg2670971 119 | inline String IpAddress2String(const IPAddress& ipAddress) { 120 | return String(ipAddress[0]) + "." + 121 | String(ipAddress[1]) + "." + 122 | String(ipAddress[2]) + "." + 123 | String(ipAddress[3]); 124 | } 125 | 126 | inline void setLed(const bool status) { 127 | #if defined(LED_BUILTIN) 128 | digitalWrite(LED_BUILTIN, status ? LOW : HIGH); // Note: LOW turn the LED on 129 | #endif 130 | } 131 | 132 | inline void telnetSend(const String line) { 133 | if (serverClient && serverClient.connected()) // send data to telnet client if connected 134 | serverClient.println(line); 135 | } 136 | 137 | bool isFloat(const String value) { 138 | for (int i = 0; i < value.length(); ++i) { 139 | char ch = value[i]; 140 | if (ch != ' ' && ch != '.' && ch != '-' && !isDigit(ch)) 141 | return false; 142 | } 143 | 144 | return true; 145 | } 146 | 147 | // Parse temperatures from printer responses like 148 | // ok T:32.8 /0.0 B:31.8 /0.0 T0:32.8 /0.0 @:0 B@:0 149 | bool parseTemp(const String response, const String whichTemp, Temperature *temperature) { 150 | int tpos = response.indexOf(whichTemp + ":"); 151 | if (tpos != -1) { // This response contains a temperature 152 | int slashpos = response.indexOf(" /", tpos); 153 | int spacepos = response.indexOf(" ", slashpos + 1); 154 | // if match mask T:xxx.xx /xxx.xx 155 | if (slashpos != -1 && spacepos != -1) { 156 | String actual = response.substring(tpos + whichTemp.length() + 1, slashpos); 157 | String target = response.substring(slashpos + 2, spacepos); 158 | if (isFloat(actual) && isFloat(target)) { 159 | temperature->actual = actual; 160 | temperature->target = target; 161 | 162 | return true; 163 | } 164 | } 165 | } 166 | 167 | return false; 168 | } 169 | 170 | // Parse temperatures from prusa firmare (sent when heating) 171 | // ok T:32.8 E:0 B:31.8 172 | bool parsePrusaHeatingTemp(const String response, const String whichTemp, Temperature *temperature) { 173 | int tpos = response.indexOf(whichTemp + ":"); 174 | if (tpos != -1) { // This response contains a temperature 175 | int spacepos = response.indexOf(" ", tpos); 176 | if (spacepos == -1) 177 | spacepos = response.length(); 178 | String actual = response.substring(tpos + whichTemp.length() + 1, spacepos); 179 | if (isFloat(actual)) { 180 | temperature->actual = actual; 181 | 182 | return true; 183 | } 184 | } 185 | 186 | return false; 187 | } 188 | 189 | int8_t parsePrusaHeatingExtruder(const String response) { 190 | Temperature tmpTemperature; 191 | 192 | return parsePrusaHeatingTemp(response, "E", &tmpTemperature) ? tmpTemperature.actual.toInt() : -1; 193 | } 194 | 195 | bool parseTemperatures(const String response) { 196 | bool tempResponse; 197 | 198 | if (fwExtruders == 1) 199 | tempResponse = parseTemp(response, "T", &toolTemperature[0]); 200 | else { 201 | tempResponse = false; 202 | for (int t = 0; t < fwExtruders; t++) 203 | tempResponse |= parseTemp(response, "T" + String(t), &toolTemperature[t]); 204 | } 205 | tempResponse |= parseTemp(response, "B", &bedTemperature); 206 | if (!tempResponse) { 207 | // Parse Prusa heating temperatures 208 | int e = parsePrusaHeatingExtruder(response); 209 | tempResponse = e >= 0 && e < MAX_SUPPORTED_EXTRUDERS && parsePrusaHeatingTemp(response, "T", &toolTemperature[e]); 210 | tempResponse |= parsePrusaHeatingTemp(response, "B", &bedTemperature); 211 | } 212 | 213 | return tempResponse; 214 | } 215 | 216 | // Parse position responses from printer like 217 | // X:-33.00 Y:-10.00 Z:5.00 E:37.95 Count X:-3300 Y:-1000 Z:2000 218 | inline bool parsePosition(const String response) { 219 | return response.indexOf("X:") != -1 && response.indexOf("Y:") != -1 && 220 | response.indexOf("Z:") != -1 && response.indexOf("E:") != -1; 221 | } 222 | 223 | inline void lcd(const String text) { 224 | commandQueue.push("M117 " + text); 225 | } 226 | 227 | inline void playSound() { 228 | commandQueue.push("M300 S500 P50"); 229 | } 230 | 231 | inline String getUploadedFilename() { 232 | return uploadedFullname == "" ? "Unknown" : uploadedFullname.substring(1); 233 | } 234 | 235 | void handlePrint() { 236 | static FileWrapper gcodeFile; 237 | static float prevM73Completion, prevM532Completion; 238 | 239 | if (isPrinting) { 240 | const bool abortPrint = (restartPrint || cancelPrint); 241 | if (abortPrint || !gcodeFile.available()) { 242 | gcodeFile.close(); 243 | if (fwProgressCap) 244 | commandQueue.push("M530 S0"); 245 | if (!abortPrint) 246 | lcd("Complete"); 247 | printPause = false; 248 | isPrinting = false; 249 | } 250 | else if (!printPause && commandQueue.getFreeSlots() > 4) { // Keep some space for "service" commands 251 | ++lastPrintedLine; 252 | String line = gcodeFile.readStringUntil('\n'); // The G-Code line being worked on 253 | filePos += line.length(); 254 | int pos = line.indexOf(';'); 255 | if (line.length() > 0 && pos != 0 && line[0] != '(' && line[0] != '\r') { 256 | if (pos != -1) 257 | line = line.substring(0, pos); 258 | commandQueue.push(line); 259 | } 260 | 261 | // Send to printer completion (if supported) 262 | printCompletion = (float)filePos / uploadedFileSize * 100; 263 | if (fwBuildPercentCap && printCompletion - prevM73Completion >= 1) { 264 | commandQueue.push("M73 P" + String((int)printCompletion)); 265 | prevM73Completion = printCompletion; 266 | } 267 | if (fwProgressCap && printCompletion - prevM532Completion >= 0.1) { 268 | commandQueue.push("M532 X" + String((int)(printCompletion * 10) / 10.0)); 269 | prevM532Completion = printCompletion; 270 | } 271 | } 272 | } 273 | 274 | if (!isPrinting && (startPrint || restartPrint)) { 275 | startPrint = restartPrint = false; 276 | 277 | filePos = 0; 278 | lastPrintedLine = 0; 279 | prevM73Completion = prevM532Completion = 0.0; 280 | 281 | gcodeFile = storageFS.open(uploadedFullname); 282 | if (!gcodeFile) 283 | lcd("Can't open file"); 284 | else { 285 | lcd("Printing..."); 286 | playSound(); 287 | printStartTime = millis(); 288 | isPrinting = true; 289 | if (fwProgressCap) { 290 | commandQueue.push("M530 S1 L0"); 291 | commandQueue.push("M531 " + getUploadedFilename()); 292 | } 293 | } 294 | } 295 | } 296 | 297 | void handleUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { 298 | static FileWrapper file; 299 | 300 | if (!index) { 301 | lcd("Receiving..."); 302 | 303 | if (uploadedFullname != "") 304 | storageFS.remove(uploadedFullname); // Remove previous file 305 | int pos = filename.lastIndexOf("/"); 306 | uploadedFullname = pos == -1 ? "/" + filename : filename.substring(pos); 307 | if (uploadedFullname.length() > storageFS.getMaxPathLength()) 308 | uploadedFullname = "/cached.gco"; // TODO maybe a different solution 309 | file = storageFS.open(uploadedFullname, "w"); // create or truncate file 310 | } 311 | 312 | file.write(data, len); 313 | 314 | if (final) { // upload finished 315 | file.close(); 316 | uploadedFileSize = index + len; 317 | } 318 | else 319 | uploadedFileSize = 0; 320 | } 321 | 322 | int apiJobHandler(JsonObject root) { 323 | const char* command = root["command"]; 324 | if (command != NULL) { 325 | if (strcmp(command, "cancel") == 0) { 326 | if (!isPrinting) 327 | return 409; 328 | cancelPrint = true; 329 | } 330 | else if (strcmp(command, "start") == 0) { 331 | if (isPrinting || !printerConnected || uploadedFullname == "") 332 | return 409; 333 | startPrint = true; 334 | } 335 | else if (strcmp(command, "restart") == 0) { 336 | if (!printPause) 337 | return 409; 338 | restartPrint = true; 339 | } 340 | else if (strcmp(command, "pause") == 0) { 341 | if (!isPrinting) 342 | return 409; 343 | const char* action = root["action"]; 344 | if (action == NULL) 345 | printPause = !printPause; 346 | else { 347 | if (strcmp(action, "pause") == 0) 348 | printPause = true; 349 | else if (strcmp(action, "resume") == 0) 350 | printPause = false; 351 | else if (strcmp(action, "toggle") == 0) 352 | printPause = !printPause; 353 | } 354 | } 355 | } 356 | 357 | return 204; 358 | } 359 | 360 | String M115ExtractString(const String response, const String field) { 361 | int spos = response.indexOf(field + ":"); 362 | if (spos != -1) { 363 | spos += field.length() + 1; 364 | int epos = response.indexOf(':', spos); 365 | if (epos == -1) 366 | epos = response.indexOf('\n', spos); 367 | if (epos == -1) 368 | return response.substring(spos); 369 | else { 370 | while (epos >= spos && response[epos] != ' ' && response[epos] != '\n') 371 | --epos; 372 | return response.substring(spos, epos); 373 | } 374 | } 375 | 376 | return ""; 377 | } 378 | 379 | bool M115ExtractBool(const String response, const String field, const bool onErrorValue = false) { 380 | String result = M115ExtractString(response, field); 381 | 382 | return result == "" ? onErrorValue : (result == "1" ? true : false); 383 | } 384 | 385 | inline String getDeviceName() { 386 | #if defined(ESP8266) 387 | return fwMachineType + " (" + String(ESP.getChipId(), HEX) + ")"; 388 | #elif defined(ESP32) 389 | uint64_t chipid = ESP.getEfuseMac(); 390 | return fwMachineType + " (" + String((uint16_t)(chipid >> 32), HEX) + String((uint32_t)chipid, HEX) + ")"; 391 | #else 392 | #error Unimplemented chip! 393 | #endif 394 | } 395 | 396 | inline String getDeviceId() { 397 | #if defined(ESP8266) 398 | return String(ESP.getChipId(), HEX); 399 | #elif defined(ESP32) 400 | uint64_t chipid = ESP.getEfuseMac(); 401 | return String((uint16_t)(chipid >> 32), HEX) + String((uint32_t)chipid, HEX); 402 | #else 403 | #error Unimplemented chip! 404 | #endif 405 | } 406 | 407 | void mDNSInit() { 408 | #ifdef OTA_UPDATES 409 | MDNS.setInstanceName(getDeviceId().c_str()); // Can't call MDNS.init because it has been already done by 'ArduinoOTA.begin', here I just change instance name 410 | #else 411 | if (!MDNS.begin(getDeviceId().c_str())) 412 | return; 413 | #endif 414 | 415 | // For Cura WirelessPrint - deprecated in favor of the OctoPrint API 416 | MDNS.addService("wirelessprint", "tcp", 80); 417 | MDNS.addServiceTxt("wirelessprint", "tcp", "version", SKETCH_VERSION); 418 | 419 | // OctoPrint API 420 | // Unfortunately, Slic3r doesn't seem to recognize it 421 | MDNS.addService("octoprint", "tcp", 80); 422 | MDNS.addServiceTxt("octoprint", "tcp", "path", "/"); 423 | MDNS.addServiceTxt("octoprint", "tcp", "api", API_VERSION); 424 | MDNS.addServiceTxt("octoprint", "tcp", "version", SKETCH_VERSION); 425 | 426 | MDNS.addService("http", "tcp", 80); 427 | MDNS.addServiceTxt("http", "tcp", "path", "/"); 428 | MDNS.addServiceTxt("http", "tcp", "api", API_VERSION); 429 | MDNS.addServiceTxt("http", "tcp", "version", SKETCH_VERSION); 430 | } 431 | 432 | bool detectPrinter() { 433 | static int printerDetectionState; 434 | static byte nM115; 435 | 436 | switch (printerDetectionState) { 437 | case 0: 438 | // Start printer detection 439 | serialBaudIndex = 0; 440 | printerDetectionState = 10; 441 | break; 442 | 443 | case 10: 444 | // Initialize baud and send a request to printezr 445 | #ifdef ESP8266 446 | PrinterSerial.begin(serialBauds[serialBaudIndex]); // See note above; we have actually renamed Serial to Serial1 447 | #endif 448 | #ifdef ESP32 449 | PrinterSerial.begin(serialBauds[serialBaudIndex], SERIAL_8N1, 32, 33); // gpio32 = rx, gpio33 = tx 450 | #endif 451 | telnetSend("Connecting at " + String(serialBauds[serialBaudIndex])); 452 | commandQueue.push("M115"); // M115 - Firmware Info 453 | printerDetectionState = 20; 454 | break; 455 | 456 | case 20: 457 | // Check if there is a printer response 458 | if (commandQueue.isEmpty()) { 459 | String value = M115ExtractString(lastReceivedResponse, "MACHINE_TYPE"); 460 | if (value == "") { 461 | if (nM115++ >= REPEAT_M115_TIMES) { 462 | nM115 = 0; 463 | ++serialBaudIndex; 464 | if (serialBaudIndex < sizeof(serialBauds) / sizeof(serialBauds[0])) 465 | printerDetectionState = 10; 466 | else 467 | printerDetectionState = 0; 468 | } 469 | else 470 | printerDetectionState = 10; 471 | } 472 | else { 473 | telnetSend("Connected"); 474 | 475 | fwMachineType = value; 476 | value = M115ExtractString(lastReceivedResponse, "EXTRUDER_COUNT"); 477 | fwExtruders = value == "" ? 1 : min(value.toInt(), (long)MAX_SUPPORTED_EXTRUDERS); 478 | fwAutoreportTempCap = M115ExtractBool(lastReceivedResponse, "Cap:AUTOREPORT_TEMP"); 479 | fwProgressCap = M115ExtractBool(lastReceivedResponse, "Cap:PROGRESS"); 480 | fwBuildPercentCap = M115ExtractBool(lastReceivedResponse, "Cap:BUILD_PERCENT"); 481 | 482 | mDNSInit(); 483 | 484 | String text = IpAddress2String(WiFi.localIP()) + " " + storageFS.getActiveFS(); 485 | lcd(text); 486 | playSound(); 487 | 488 | if (fwAutoreportTempCap) 489 | commandQueue.push(AUTOTEMP_COMMAND + String(TEMPERATURE_REPORT_INTERVAL)); // Start auto report temperatures 490 | else 491 | temperatureTimer = millis(); 492 | return true; 493 | } 494 | } 495 | break; 496 | } 497 | 498 | return false; 499 | } 500 | 501 | void initUploadedFilename() { 502 | FileWrapper dir = storageFS.open("/"); 503 | if (dir) { 504 | FileWrapper file = dir.openNextFile(); 505 | while (file && file.isDirectory()) { 506 | file.close(); 507 | file = dir.openNextFile(); 508 | } 509 | if (file) { 510 | uploadedFullname = "/" + file.name(); 511 | uploadedFileSize = file.size(); 512 | file.close(); 513 | } 514 | dir.close(); 515 | } 516 | } 517 | 518 | inline String getState() { 519 | if (!printerConnected) 520 | return "Discovering printer"; 521 | else if (cancelPrint) 522 | return "Cancelling"; 523 | else if (printPause) 524 | return "Paused"; 525 | else if (isPrinting) 526 | return "Printing"; 527 | else 528 | return "Operational"; 529 | } 530 | 531 | inline String stringify(bool value) { 532 | return value ? "true" : "false"; 533 | } 534 | 535 | void setup() { 536 | #if defined(LED_BUILTIN) 537 | pinMode(LED_BUILTIN, OUTPUT); // Initialize the LED_BUILTIN pin as an output 538 | #endif 539 | 540 | #ifdef USE_FAST_SD 541 | storageFS.begin(true); 542 | #else 543 | storageFS.begin(false); 544 | #endif 545 | 546 | for (int t = 0; t < MAX_SUPPORTED_EXTRUDERS; t++) 547 | toolTemperature[t] = { "0.0", "0.0" }; 548 | bedTemperature = { "0.0", "0.0" }; 549 | 550 | // Wait for connection 551 | setLed(true); 552 | #ifdef OTA_UPDATES 553 | AsyncElegantOTA.begin(&server); 554 | #endif 555 | AsyncWiFiManager wifiManager(&server, &dns); 556 | // wifiManager.resetSettings(); // Uncomment this to reset the settings on the device, then you will need to reflash with USB and this commented out! 557 | wifiManager.setDebugOutput(false); // So that it does not send stuff to the printer that the printer does not understand 558 | wifiManager.autoConnect("AutoConnectAP"); 559 | setLed(false); 560 | 561 | telnetServer.begin(); 562 | telnetServer.setNoDelay(true); 563 | 564 | if (storageFS.activeSPIFFS()) { 565 | #if defined(ESP8266) 566 | server.addHandler(new SPIFFSEditor()); 567 | #elif defined(ESP32) 568 | server.addHandler(new SPIFFSEditor(SPIFFS)); 569 | #else 570 | #error Unsupported SOC 571 | #endif 572 | } 573 | 574 | initUploadedFilename(); 575 | 576 | server.onNotFound([](AsyncWebServerRequest * request) { 577 | telnetSend("404 | Page '" + request->url() + "' not found"); 578 | request->send(404, "text/html", "

Page not found!

"); 579 | }); 580 | 581 | // Main page 582 | server.on("/", HTTP_GET, [](AsyncWebServerRequest * request) { 583 | String uploadedName = uploadedFullname; 584 | uploadedName.replace("/", ""); 585 | String message = "

" + getDeviceName() + "

" 586 | "
\n" 587 | "

You can also print from the command line using curl:

\n" 588 | "
curl -F \"file=@/path/to/some.gcode\" -F \"print=true\" " + IpAddress2String(WiFi.localIP()) + "/api/files/local
\n" 589 | "Choose a file to upload:
\n" 590 | "\n" 591 | "
\n" 592 | "\n" 593 | "
" 594 | "

\n\n

\n" 595 | "

Download " + uploadedName + "

" 596 | "

Info

" 597 | "
" 598 | "

WirelessPrinting " + SKETCH_VERSION + "

\n" 599 | #ifdef OTA_UPDATES 600 | "

OTA Update Device: Click Here

" 601 | #endif 602 | ; 603 | request->send(200, "text/html", message); 604 | }); 605 | 606 | // Info page 607 | server.on("/info", HTTP_GET, [](AsyncWebServerRequest * request) { 608 | String message = "
"
 609 |                      "Free heap: " + String(ESP.getFreeHeap()) + "\n\n"
 610 |                      "File system: " + storageFS.getActiveFS() + "\n";
 611 |     if (storageFS.isActive()) {
 612 |       message += "Filename length limit: " + String(storageFS.getMaxPathLength()) + "\n";
 613 |       if (uploadedFullname != "") {
 614 |         message += "Uploaded file: " + getUploadedFilename() + "\n"
 615 |                    "Uploaded file size: " + String(uploadedFileSize) + "\n";
 616 |       }
 617 |     }
 618 |     message += "\n"
 619 |                "Last command sent: " + lastCommandSent + "\n"
 620 |                "Last received response: " + lastReceivedResponse + "\n";
 621 |     if (printerConnected) {
 622 |       message += "\n"
 623 |                  "EXTRUDER_COUNT: " + String(fwExtruders) + "\n"
 624 |                  "AUTOREPORT_TEMP: " + stringify(fwAutoreportTempCap);
 625 |       if (fwAutoreportTempCap)
 626 |         message += " Enabled: " + stringify(autoreportTempEnabled);
 627 |       message += "\n"
 628 |                  "PROGRESS: " + stringify(fwProgressCap) + "\n"
 629 |                  "BUILD_PERCENT: " + stringify(fwBuildPercentCap) + "\n";
 630 |     }
 631 |     message += "
"; 632 | request->send(200, "text/html", message); 633 | }); 634 | 635 | // Download page 636 | server.on("/download", HTTP_GET, [](AsyncWebServerRequest * request) { 637 | AsyncWebServerResponse *response = request->beginResponse("application/x-gcode", uploadedFileSize, [](uint8_t *buffer, size_t maxLen, size_t index) -> size_t { 638 | static size_t downloadBytesLeft; 639 | static FileWrapper downloadFile; 640 | 641 | if (!index) { 642 | downloadFile = storageFS.open(uploadedFullname); 643 | downloadBytesLeft = uploadedFileSize; 644 | } 645 | size_t bytes = min(downloadBytesLeft, maxLen); 646 | bytes = min(bytes, (size_t)2048); 647 | bytes = downloadFile.read(buffer, bytes); 648 | downloadBytesLeft -= bytes; 649 | if (bytes <= 0) 650 | downloadFile.close(); 651 | 652 | return bytes; 653 | }); 654 | response->addHeader("Content-Disposition", "attachment; filename=\"" + getUploadedFilename()+ "\""); 655 | request->send(response); 656 | }); 657 | 658 | server.on("/api/login", HTTP_POST, [](AsyncWebServerRequest * request) { 659 | // https://docs.octoprint.org/en/master/api/general.html#post--api-login 660 | // https://github.com/fieldOfView/Cura-OctoPrintPlugin/issues/155#issuecomment-596109663 661 | request->send(200, "application/json", "{}"); }); 662 | 663 | server.on("/api/version", HTTP_GET, [](AsyncWebServerRequest * request) { 664 | // http://docs.octoprint.org/en/master/api/version.html 665 | request->send(200, "application/json", "{\r\n" 666 | " \"api\": \"" API_VERSION "\",\r\n" 667 | " \"server\": \"" VERSION "\"\r\n" 668 | "}"); }); 669 | 670 | server.on("/api/connection", HTTP_GET, [](AsyncWebServerRequest * request) { 671 | // http://docs.octoprint.org/en/master/api/connection.html#get-connection-settings 672 | request->send(200, "application/json", "{\r\n" 673 | " \"current\": {\r\n" 674 | " \"state\": \"" + getState() + "\",\r\n" 675 | " \"port\": \"Serial\",\r\n" 676 | " \"baudrate\": " + serialBauds[serialBaudIndex] + ",\r\n" 677 | " \"printerProfile\": \"Default\"\r\n" 678 | " },\r\n" 679 | " \"options\": {\r\n" 680 | " \"ports\": \"Serial\",\r\n" 681 | " \"baudrate\": " + serialBauds[serialBaudIndex] + ",\r\n" 682 | " \"printerProfiles\": \"Default\",\r\n" 683 | " \"portPreference\": \"Serial\",\r\n" 684 | " \"baudratePreference\": " + serialBauds[serialBaudIndex] + ",\r\n" 685 | " \"printerProfilePreference\": \"Default\",\r\n" 686 | " \"autoconnect\": true\r\n" 687 | " }\r\n" 688 | "}"); 689 | }); 690 | 691 | // Todo: http://docs.octoprint.org/en/master/api/connection.html#post--api-connection 692 | 693 | // File Operations 694 | // Pending: http://docs.octoprint.org/en/master/api/files.html#retrieve-all-files 695 | server.on("/api/files", HTTP_GET, [](AsyncWebServerRequest * request) { 696 | request->send(200, "application/json", "{\r\n" 697 | " \"files\": {\r\n" 698 | " }\r\n" 699 | "}"); 700 | }); 701 | 702 | // For Slic3r OctoPrint compatibility 703 | server.on("/api/files/local", HTTP_POST, [](AsyncWebServerRequest * request) { 704 | // https://docs.octoprint.org/en/master/api/files.html?highlight=api%2Ffiles%2Flocal#upload-file-or-create-folder 705 | lcd("Received"); 706 | playSound(); 707 | 708 | // We are not using 709 | // if (request->hasParam("print", true)) 710 | // due to https://github.com/fieldOfView/Cura-OctoPrintPlugin/issues/156 711 | 712 | startPrint = printerConnected && !isPrinting && uploadedFullname != ""; 713 | 714 | // OctoPrint sends 201 here; https://github.com/fieldOfView/Cura-OctoPrintPlugin/issues/155#issuecomment-596110996 715 | request->send(201, "application/json", "{\r\n" 716 | " \"files\": {\r\n" 717 | " \"local\": {\r\n" 718 | " \"name\": \"" + getUploadedFilename() + "\",\r\n" 719 | " \"origin\": \"local\"\r\n" 720 | " }\r\n" 721 | " },\r\n" 722 | " \"done\": true\r\n" 723 | "}"); 724 | }, handleUpload); 725 | 726 | server.on("/api/job", HTTP_GET, [](AsyncWebServerRequest * request) { 727 | // http://docs.octoprint.org/en/master/api/job.html#retrieve-information-about-the-current-job 728 | int32_t printTime = 0, printTimeLeft = 0; 729 | if (isPrinting) { 730 | printTime = (millis() - printStartTime) / 1000; 731 | printTimeLeft = (printCompletion > 0) ? printTime / printCompletion * (100 - printCompletion) : INT32_MAX; 732 | } 733 | request->send(200, "application/json", "{\r\n" 734 | " \"job\": {\r\n" 735 | " \"file\": {\r\n" 736 | " \"name\": \"" + getUploadedFilename() + "\",\r\n" 737 | " \"origin\": \"local\",\r\n" 738 | " \"size\": " + String(uploadedFileSize) + ",\r\n" 739 | " \"date\": " + String(uploadedFileDate) + "\r\n" 740 | " },\r\n" 741 | //" \"estimatedPrintTime\": \"" + estimatedPrintTime + "\",\r\n" 742 | " \"filament\": {\r\n" 743 | //" \"length\": \"" + filementLength + "\",\r\n" 744 | //" \"volume\": \"" + filementVolume + "\"\r\n" 745 | " }\r\n" 746 | " },\r\n" 747 | " \"progress\": {\r\n" 748 | " \"completion\": " + String(printCompletion) + ",\r\n" 749 | " \"filepos\": " + String(filePos) + ",\r\n" 750 | " \"printTime\": " + String(printTime) + ",\r\n" 751 | " \"printTimeLeft\": " + String(printTimeLeft) + "\r\n" 752 | " },\r\n" 753 | " \"state\": \"" + getState() + "\"\r\n" 754 | "}"); 755 | }); 756 | 757 | server.on("/api/job", HTTP_POST, [](AsyncWebServerRequest *request) { 758 | // Job commands http://docs.octoprint.org/en/master/api/job.html#issue-a-job-command 759 | request->send(200, "text/plain", ""); 760 | }, 761 | [](AsyncWebServerRequest *request, const String& filename, size_t index, uint8_t *data, size_t len, bool final) { 762 | request->send(400, "text/plain", "file not supported"); 763 | }, 764 | [](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { 765 | static String content; 766 | 767 | if (!index) 768 | content = ""; 769 | for (int i = 0; i < len; ++i) 770 | content += (char)data[i]; 771 | if (content.length() >= total) { 772 | DynamicJsonDocument doc(1024); 773 | auto error = deserializeJson(doc, content); 774 | if (error) 775 | request->send(400, "text/plain", error.c_str()); 776 | else { 777 | int responseCode = apiJobHandler(doc.as()); 778 | request->send(responseCode, "text/plain", ""); 779 | content = ""; 780 | } 781 | } 782 | }); 783 | 784 | server.on("/api/settings", HTTP_GET, [](AsyncWebServerRequest * request) { 785 | // https://github.com/probonopd/WirelessPrinting/issues/30 786 | // https://github.com/probonopd/WirelessPrinting/issues/18#issuecomment-321927016 787 | request->send(200, "application/json", "{}"); 788 | }); 789 | 790 | server.on("/api/printer", HTTP_GET, [](AsyncWebServerRequest * request) { 791 | // https://docs.octoprint.org/en/master/api/printer.html#retrieve-the-current-printer-state 792 | String readyState = stringify(printerConnected); 793 | String message = "{\r\n" 794 | " \"state\": {\r\n" 795 | " \"text\": \"" + getState() + "\",\r\n" 796 | " \"flags\": {\r\n" 797 | " \"operational\": " + readyState + ",\r\n" 798 | " \"paused\": " + stringify(printPause) + ",\r\n" 799 | " \"printing\": " + stringify(isPrinting) + ",\r\n" 800 | " \"pausing\": false,\r\n" 801 | " \"cancelling\": " + stringify(cancelPrint) + ",\r\n" 802 | " \"sdReady\": false,\r\n" 803 | " \"error\": false,\r\n" 804 | " \"ready\": " + readyState + ",\r\n" 805 | " \"closedOrError\": " + stringify(!printerConnected) + "\r\n" 806 | " }\r\n" 807 | " },\r\n" 808 | " \"temperature\": {\r\n"; 809 | for (int t = 0; t < fwExtruders; ++t) { 810 | message += " \"tool" + String(t) + "\": {\r\n" 811 | " \"actual\": " + toolTemperature[t].actual + ",\r\n" 812 | " \"target\": " + toolTemperature[t].target + ",\r\n" 813 | " \"offset\": 0\r\n" 814 | " },\r\n"; 815 | } 816 | message += " \"bed\": {\r\n" 817 | " \"actual\": " + bedTemperature.actual + ",\r\n" 818 | " \"target\": " + bedTemperature.target + ",\r\n" 819 | " \"offset\": 0\r\n" 820 | " }\r\n" 821 | " },\r\n" 822 | " \"sd\": {\r\n" 823 | " \"ready\": false\r\n" 824 | " }\r\n" 825 | "}"; 826 | request->send(200, "application/json", message); 827 | }); 828 | 829 | server.on("/api/printer/command", HTTP_POST, [](AsyncWebServerRequest *request) { 830 | // http://docs.octoprint.org/en/master/api/printer.html#send-an-arbitrary-command-to-the-printer 831 | request->send(200, "text/plain", ""); 832 | }, 833 | [](AsyncWebServerRequest *request, const String& filename, size_t index, uint8_t *data, size_t len, bool final) { 834 | request->send(400, "text/plain", "file not supported"); 835 | }, 836 | [](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { 837 | static String content; 838 | 839 | if (!index) 840 | content = ""; 841 | for (size_t i = 0; i < len; ++i) 842 | content += (char)data[i]; 843 | if (content.length() >= total) { 844 | DynamicJsonDocument doc(1024); 845 | auto error = deserializeJson(doc, content); 846 | if (error) 847 | request->send(400, "text/plain", error.c_str()); 848 | else { 849 | JsonObject root = doc.as(); 850 | const char* command = root["command"]; 851 | if (command != NULL) 852 | commandQueue.push(command); 853 | else { 854 | JsonArray commands = root["commands"].as(); 855 | for (JsonVariant command : commands) 856 | commandQueue.push(String(command.as())); 857 | } 858 | request->send(204, "text/plain", ""); 859 | } 860 | content = ""; 861 | } 862 | }); 863 | 864 | // For legacy PrusaControlWireless - deprecated in favor of the OctoPrint API 865 | server.on("/print", HTTP_POST, [](AsyncWebServerRequest * request) { 866 | request->send(200, "text/plain", "Received"); 867 | }, handleUpload); 868 | 869 | // For legacy Cura WirelessPrint - deprecated in favor of the OctoPrint API 870 | server.on("/api/print", HTTP_POST, [](AsyncWebServerRequest * request) { 871 | request->send(200, "text/plain", "Received"); 872 | }, handleUpload); 873 | 874 | server.begin(); 875 | 876 | #ifdef OTA_UPDATES 877 | // OTA setup 878 | ArduinoOTA.setHostname(getDeviceId().c_str()); 879 | #ifdef OTA_PASSWORD 880 | ArduinoOTA.setPassword(OTA_PASSWORD); 881 | #endif 882 | ArduinoOTA.begin(); 883 | #endif 884 | } 885 | 886 | inline void restartSerialTimeout() { 887 | serialReceiveTimeoutTimer = millis() + KEEPALIVE_INTERVAL; 888 | } 889 | 890 | void SendCommands() { 891 | String command = commandQueue.peekSend(); //gets the next command to be sent 892 | if (command != "") { 893 | bool noResponsePending = commandQueue.isAckEmpty(); 894 | if (noResponsePending || printerUsedBuffer < PRINTER_RX_BUFFER_SIZE * 3 / 4) { // Let's use no more than 75% of printer RX buffer 895 | if (noResponsePending) 896 | restartSerialTimeout(); // Receive timeout has to be reset only when sending a command and no pending response is expected 897 | PrinterSerial.println(command); // Send to 3D Printer 898 | printerUsedBuffer += command.length(); 899 | lastCommandSent = command; 900 | commandQueue.popSend(); 901 | 902 | telnetSend(">" + command); 903 | } 904 | } 905 | } 906 | 907 | void ReceiveResponses() { 908 | static int lineStartPos; 909 | static String serialResponse; 910 | 911 | while (PrinterSerial.available()) { 912 | char ch = (char)PrinterSerial.read(); 913 | if (ch != '\n') 914 | serialResponse += ch; 915 | else { 916 | bool incompleteResponse = false; 917 | String responseDetail = ""; 918 | 919 | if (serialResponse.startsWith("ok", lineStartPos)) { 920 | if (lastCommandSent.startsWith(TEMP_COMMAND)) 921 | parseTemperatures(serialResponse); 922 | else if (fwAutoreportTempCap && lastCommandSent.startsWith(AUTOTEMP_COMMAND)) 923 | autoreportTempEnabled = (lastCommandSent[6] != '0'); 924 | 925 | unsigned int cmdLen = commandQueue.popAcknowledge().length(); // Go on with next command 926 | printerUsedBuffer = max(printerUsedBuffer - cmdLen, 0u); 927 | responseDetail = "ok"; 928 | } 929 | else if (printerConnected) { 930 | if (parseTemperatures(serialResponse)) 931 | responseDetail = "autotemp"; 932 | else if (parsePosition(serialResponse)) 933 | responseDetail = "position"; 934 | else if (serialResponse.startsWith("echo:busy")) 935 | responseDetail = "busy"; 936 | else if (serialResponse.startsWith("echo: cold extrusion prevented")) { 937 | // To do: Pause sending gcode, or do something similar 938 | responseDetail = "cold extrusion"; 939 | } 940 | else if (serialResponse.startsWith("Error:")) { 941 | cancelPrint = true; 942 | responseDetail = "ERROR"; 943 | } 944 | else { 945 | incompleteResponse = true; 946 | responseDetail = "wait more"; 947 | } 948 | } else { 949 | incompleteResponse = true; 950 | responseDetail = "discovering"; 951 | } 952 | 953 | int responseLength = serialResponse.length(); 954 | telnetSend("<" + serialResponse.substring(lineStartPos, responseLength) + "#" + responseDetail + "#"); 955 | if (incompleteResponse) 956 | lineStartPos = responseLength; 957 | else { 958 | lastReceivedResponse = serialResponse; 959 | lineStartPos = 0; 960 | serialResponse = ""; 961 | } 962 | restartSerialTimeout(); 963 | } 964 | } 965 | 966 | if (!commandQueue.isAckEmpty() && (signed)(serialReceiveTimeoutTimer - millis()) <= 0) { // Command has been lost by printer, buffer has been freed 967 | if (printerConnected) 968 | telnetSend("#TIMEOUT#"); 969 | else 970 | commandQueue.clear(); 971 | lineStartPos = 0; 972 | serialResponse = ""; 973 | restartSerialTimeout(); 974 | } 975 | // this resets all the neopixels to an off state 976 | strip.Begin(); 977 | strip.Show(); 978 | // strip.SetPixelColor(0, red); 979 | // strip.SetPixelColor(1, green); 980 | // strip.SetPixelColor(2, blue); 981 | // strip.SetPixelColor(3, white); 982 | int a; 983 | for(a=0; a 0) { 1058 | commandQueue.push(telnetCommand); 1059 | telnetCommand = ""; 1060 | } 1061 | } 1062 | else 1063 | telnetCommand += ch; 1064 | } 1065 | } 1066 | 1067 | #ifdef OTA_UPDATES 1068 | AsyncElegantOTA.loop(); 1069 | #endif 1070 | } 1071 | -------------------------------------------------------------------------------- /ESP8266WirelessPrintAsync/FileWrapper.cpp: -------------------------------------------------------------------------------- 1 | #include "FileWrapper.h" 2 | #include "StorageFS.h" 3 | 4 | size_t FileWrapper::write(uint8_t b) { 5 | uint8_t buf[] = { b }; 6 | 7 | return write(buf, 1); 8 | } 9 | 10 | size_t FileWrapper::write(const uint8_t *buf, size_t len) { 11 | if (sdFile) 12 | return sdFile.write(buf, len); 13 | else if (fsFile) { 14 | #if defined(ESP8266) 15 | ESP.wdtDisable(); 16 | #endif 17 | size_t wb = fsFile.write(buf, len); 18 | #if defined(ESP8266) 19 | ESP.wdtEnable(250); 20 | #endif 21 | return wb; 22 | } 23 | 24 | return 0; 25 | } 26 | 27 | void FileWrapper::flush() { 28 | if (sdFile) 29 | return sdFile.flush(); 30 | else if (fsFile) 31 | return fsFile.flush(); 32 | } 33 | 34 | int FileWrapper::available() { 35 | return sdFile ? sdFile.available() : (fsFile ? fsFile.available() : false); 36 | } 37 | 38 | int FileWrapper::peek() { 39 | if (sdFile) 40 | return sdFile.peek(); 41 | else if (fsFile) 42 | return fsFile.peek(); 43 | 44 | return -1; 45 | } 46 | 47 | int FileWrapper::read() { 48 | if (sdFile) 49 | return sdFile.read(); 50 | else if (fsFile) 51 | return fsFile.read(); 52 | 53 | return -1; 54 | } 55 | 56 | String FileWrapper::name() { 57 | if (sdFile) { 58 | #if defined(ESP8266) 59 | if (cachedName == "") { 60 | const int maxPathLength = StorageFS::getMaxPathLength(); 61 | char *namePtr = (char *)malloc(maxPathLength + 1); 62 | sdFile.getName(namePtr, maxPathLength); 63 | cachedName = String(namePtr); 64 | free (namePtr); 65 | } 66 | 67 | return cachedName; 68 | #elif defined(ESP32) 69 | return sdFile.name(); 70 | #endif 71 | } 72 | else { 73 | #if defined(ESP8266) 74 | if (fsDirType != DirEntry) 75 | return ""; 76 | 77 | String name = fsDir.fileName(); 78 | int i = name.lastIndexOf("/"); 79 | 80 | return i == -1 ? name : name.substring(i + 1); 81 | #elif defined(ESP32) 82 | return fsFile.name(); 83 | #endif 84 | } 85 | } 86 | 87 | uint32_t FileWrapper::size() { 88 | if (sdFile) 89 | return sdFile.size(); 90 | else if (fsFile) 91 | return fsFile.size(); 92 | #if defined(ESP8266) 93 | else if (fsDirType == DirEntry) 94 | return fsDir.fileSize(); 95 | #endif 96 | 97 | return 0; 98 | } 99 | 100 | size_t FileWrapper::read(uint8_t *buf, size_t size) { 101 | if (sdFile) 102 | return sdFile.read(buf, size); 103 | else if (fsFile) 104 | return fsFile.read(buf, size); 105 | 106 | return 0; 107 | } 108 | 109 | String FileWrapper::readStringUntil(char eol) { 110 | return sdFile ? sdFile.readStringUntil(eol) : (fsFile ? fsFile.readStringUntil(eol) : ""); 111 | } 112 | 113 | void FileWrapper::close() { 114 | if (sdFile) { 115 | sdFile.close(); 116 | sdFile = File(); 117 | } 118 | else if (fsFile) { 119 | fsFile.close(); 120 | fsFile = fs::File(); 121 | } 122 | #if defined(ESP8266) 123 | else if (fsDirType != Null) { 124 | fsDir = fs::Dir(); 125 | fsDirType = Null; 126 | } 127 | #endif 128 | } 129 | 130 | FileWrapper FileWrapper::openNextFile() { 131 | FileWrapper fw = FileWrapper(); 132 | 133 | if (sdFile) 134 | fw.sdFile = sdFile.openNextFile(); 135 | #if defined(ESP8266) 136 | else if (fsDirType == DirSource) { 137 | if (fsDir.next()) { 138 | fw.fsDir = fsDir; 139 | fw.fsDirType = DirEntry; 140 | } 141 | } 142 | #elif defined(ESP32) 143 | else if (fsFile) 144 | fw.fsFile = fsFile.openNextFile(); 145 | #endif 146 | 147 | return fw; 148 | } 149 | -------------------------------------------------------------------------------- /ESP8266WirelessPrintAsync/FileWrapper.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #define FS_NO_GLOBALS // allow spiffs to coexist with SD card, define BEFORE including FS.h 4 | #include 5 | #if defined(ESP8266) 6 | #include 7 | #elif defined(ESP32) 8 | #include 9 | #define FORMAT_SPIFFS_IF_FAILED true 10 | #include 11 | #endif 12 | 13 | class FileWrapper : public Stream { 14 | friend class StorageFS; 15 | 16 | private: 17 | File sdFile; 18 | fs::File fsFile; 19 | #if defined(ESP8266) 20 | enum FSDirType { Null, DirSource, DirEntry }; 21 | 22 | String cachedName; 23 | fs::Dir fsDir; 24 | FSDirType fsDirType; 25 | #endif 26 | 27 | public: 28 | // Print methods 29 | virtual size_t write(uint8_t datum); 30 | virtual size_t write(const uint8_t *buf, size_t size); 31 | 32 | // Stream methods 33 | virtual void flush(); 34 | virtual int available(); 35 | virtual int peek(); 36 | virtual int read(); 37 | 38 | inline operator bool() { 39 | return sdFile || fsFile 40 | #if defined(ESP8266) 41 | || fsDirType != Null; 42 | #endif 43 | ; 44 | } 45 | 46 | String name(); 47 | uint32_t size(); 48 | size_t read(uint8_t *buf, size_t size); 49 | String readStringUntil(char eol); 50 | void close(); 51 | 52 | inline bool isDirectory() { 53 | if (sdFile) 54 | return sdFile.isDirectory(); 55 | #if defined(ESP8266) 56 | return fsDirType == DirSource; 57 | #else 58 | return fsFile ? fsFile.isDirectory() : false; 59 | #endif 60 | } 61 | 62 | FileWrapper openNextFile(); 63 | }; 64 | -------------------------------------------------------------------------------- /ESP8266WirelessPrintAsync/StorageFS.cpp: -------------------------------------------------------------------------------- 1 | #include "StorageFS.h" 2 | 3 | #if defined(ESP8266) 4 | SdFat SD; 5 | #endif 6 | StorageFS storageFS; 7 | 8 | bool StorageFS::hasSD, 9 | StorageFS::hasSPIFFS; 10 | unsigned int StorageFS::maxPathLength; 11 | 12 | 13 | FileWrapper StorageFS::open(const String path, const char *openMode) { 14 | FileWrapper file; 15 | 16 | if (openMode == NULL || openMode[0] == '\0') 17 | return file; 18 | 19 | if (hasSD) { 20 | #if defined(ESP8266) 21 | file.sdFile = SD.open(path.c_str(), openMode[0] == 'w' ? (O_WRITE | O_CREAT | O_TRUNC) : FILE_READ); 22 | if (file && file.sdFile.isDirectory()) 23 | file.sdFile.rewindDirectory(); 24 | #elif defined(ESP32) 25 | file.sdFile = SD.open(path, openMode); 26 | #endif 27 | } 28 | else if (hasSPIFFS) { 29 | #if defined(ESP8266) 30 | if (path.endsWith("/")) { 31 | file.fsDir = SPIFFS.openDir(path); 32 | file.fsDirType = FileWrapper::DirSource; 33 | } 34 | else 35 | file.fsFile = SPIFFS.open(path, openMode); 36 | #elif defined(ESP32) 37 | file.fsFile = SPIFFS.open(path, openMode); 38 | #endif 39 | } 40 | 41 | return file; 42 | } 43 | 44 | void StorageFS::remove(const String filename) { 45 | if (hasSD) 46 | SD.remove(filename.c_str()); 47 | else if (hasSPIFFS) 48 | SPIFFS.remove(filename); 49 | } 50 | -------------------------------------------------------------------------------- /ESP8266WirelessPrintAsync/StorageFS.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "FileWrapper.h" 4 | 5 | #if defined(ESP8266) 6 | extern SdFat SD; 7 | #endif 8 | 9 | class StorageFS { 10 | private: 11 | static bool hasSD, 12 | hasSPIFFS; 13 | static unsigned int maxPathLength; 14 | 15 | public: 16 | inline static void begin(const bool fastSD) { 17 | #if defined(ESP8266) 18 | hasSD = SD.begin(SS, fastSD ? SD_SCK_MHZ(50) : SPI_HALF_SPEED); // https://github.com/esp8266/Arduino/issues/1853 19 | #elif defined(ESP32) 20 | SPI.begin(14, 2, 15, 13); // TTGO-T1 V1.3 internal microSD slot 21 | hasSD = SD.begin(SS, SPI, fastSD ? 50000000 : 4000000); 22 | #endif 23 | if (hasSD) 24 | maxPathLength = 255; 25 | else { 26 | #if defined(ESP8266) 27 | hasSPIFFS = SPIFFS.begin(); 28 | if (hasSPIFFS) { 29 | fs::FSInfo fs_info; 30 | maxPathLength = SPIFFS.info(fs_info) ? fs_info.maxPathLength - 1 : 11; 31 | } 32 | #elif defined(ESP32) 33 | hasSPIFFS = SPIFFS.begin(true); 34 | maxPathLength = 11; 35 | #endif 36 | } 37 | } 38 | 39 | inline static bool activeSD() { 40 | return hasSD; 41 | } 42 | 43 | inline static bool activeSPIFFS() { 44 | return hasSPIFFS; 45 | } 46 | 47 | inline static bool isActive() { 48 | return activeSD() || activeSPIFFS(); 49 | } 50 | 51 | inline static String getActiveFS() { 52 | return activeSD() ? "SD" : (activeSPIFFS() ? "SPIFFS" : "NO FS"); 53 | } 54 | 55 | inline static unsigned int getMaxPathLength() { 56 | return maxPathLength; 57 | } 58 | 59 | static FileWrapper open(const String path, const char *openMode = "r"); 60 | static void remove(const String filename); 61 | }; 62 | 63 | extern StorageFS storageFS; 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WirelessPrinting [![Build Status](https://github.com/probonopd/WirelessPrinting/actions/workflows/platformio.yml/badge.svg)](https://github.com/probonopd/WirelessPrinting/actions/workflows/platformio.yml) 2 | 3 | ![](https://user-images.githubusercontent.com/2480569/53683404-5b21ab80-3cf8-11e9-8a6e-647df742612b.jpg) 4 | 5 | Print wirelessly from [Cura](https://ultimaker.com/en/products/cura-software), [PrusaControl](http://prusacontrol.org/), or [Slic3r PE](https://github.com/prusa3d/Slic3r/releases) to your 3D printer connected to an [ESP8266](https://espressif.com/en/products/hardware/esp8266ex/overview) module. 6 | 7 | __UNDER DEVELOPMENT__. See [Issues](https://github.com/probonopd/WirelessPrinting/issues). Pull requests welcome! 8 | 9 | ## Comparison with other printer hosts 10 | 11 | | Printer SD card slot | OctoPrint | WirelessPrint | 12 | | --- | --- | --- | 13 | | Instant | Booting can take minutes | Booting takes seconds | 14 | | Need to plug SD card into computer and then into printer for each print | Ethernet and wireless | Wireless | 15 | | No cost (comes with many printers) | High cost (Raspberry Pi, Power supply, SD card) | Inexpensive | 16 | | No clutter on desktop | Clutter on desktop (Raspberry Pi, cable) | No clutter (can be placed inside printer electronics box) | 17 | | No set-up needed | Set-up needed (full Linux operating system, hundreds of megabytes) | Only quick wireless network setup needed | 18 | | No maintenance needed (other than replacing broken SD card slots) | High maintenance needed (OS updates) | Low maintenance needed (Firmware updates for bugfixes and new features) | 19 | | No extra power consumption | 2.5 W power consumption | Under 1 W power consumption | 20 | | No webcam | Webcam can be attached | ESP32 module with built-in camera (may be supported in the future) | 21 | | No notifications | Notifications, e.g., "print ready" | Notifications possible (send pull requests) | 22 | | Cumbersome for print farms (sneakernet) | Suitable for print farms (can be managed centrally) | Suitable for print farms (can be managed centrally, OctoPrint compatible protocol subset) | 23 | 24 | ## Hardware 25 | 26 | WEMOS D1 mini modules can be used. Also, ESP32 modules can be used (e.g., TTGO-T1 with built-in microSD card slot). 27 | 28 | The WEMOS D1 mini module is connected with your 3D printer via the serial connection and to a SD card (acting as a cache during printing). You need to connect 29 | * TX, RX from your 3D printer to the WEMOS D1 mini module (__AUX-1__ header on RAMPS boards). Note: For ESP32, use GPIO32 = RX, GPIO33 = TX 30 | * Power and GND from your 3D printer to the WEMOS D1 mini module (attention, the __AUX-1__ header on RAMPS boards has 5V while the ESP8266 needs 3.3V but the WEMOS D1 mini has a voltage regulator) 31 | * Optional: SD card shield to the WEMOS D1 mini module (a capacitor across the power pins of the SD card; SD shields have this). Using a SanDisk 2 GB card formatted with `mkfs.vfat` on Linux seems to work for me. If no SD card is connected, then the internal SPIFFS memory (3 MB) is used. For TTGO-T1, the built-in microSD card slot is used if a card is inserted. 32 | * A matching case for a WEMOS D1 mini module and microSD shield can be found at http://www.thingiverse.com/thing:2287618 33 | 34 | ## esp8266/Arduino sketch 35 | 36 | The [esp8266/Arduino](https://github.com/esp8266/Arduino) sketch `ESP8266WirelessPrintAsync.ino` is uploaded to a ESP8266 module. See `.travis.yml` for how this is compiled on Travis CI. 37 | 38 | ### Building 39 | 40 | Pre-built binaries are available for download on [GitHub Releases](https://github.com/probonopd/WirelessPrinting/releases). 41 | 42 | The following build procedure works on Linux: 43 | 44 | ``` 45 | # Get PlatformIO (the toolchain we use for compiling) 46 | git clone https://github.com/probonopd/WirelessPrinting 47 | cd WirelessPrinting 48 | wget -c https://downloads.egenix.com/python/install-pyrun 49 | bash install-pyrun --python=3.5 pyrun/ 50 | pyrun/bin/pip3 install -U platformio==4.2.1 51 | 52 | # Build the firmware (it downloads the needed libraries) 53 | pyrun/bin/platformio run 54 | find . -name "*firmware.bin" 55 | ``` 56 | 57 | ### Flashing from Linux 58 | 59 | Can be flashed via USB or (subsequently) over the air. You can use PlatformIO to upload to either OTA and flash via any known flash method. See e.g., https://docs.platformio.org/en/latest/platforms/espressif8266.html#over-the-air-ota-update, https://docs.platformio.org/en/latest/platforms/espressif32.html#packages. 60 | 61 | If you are not using PlatformIO (e.g., because you are just interested in uploading our pre-built firmware as quickly as possible) you may use the following instructions. 62 | 63 | #### ESP8266 64 | 65 | ``` 66 | # USB 67 | sudo chmod a+rwx /dev/ttyUSB0 ; /tmp/.mount_*/usr/bin/hardware/esp8266/esp8266/tools/esptool/esptool -vv -cd nodemcu -cb 921600 -cp /dev/ttyUSB0 -ca 0x00000 -cf ESP8266WirelessPrint*.bin 68 | 69 | # Wireless 70 | wget -c "https://raw.githubusercontent.com/esp8266/Arduino/master/tools/espota.py" 71 | python espota.py -i 192.168.0.27 -p 8266 --auth= -f ESP8266WirelessPrint*.bin 72 | ``` 73 | 74 | ##### ESP32 75 | 76 | ``` 77 | # USB 78 | sudo apt install python-serial 79 | sudo chmod a+rwx /dev/ttyUSB0 ; python $HOME/.arduino15/packages/esp32/tools/esptool_py/2.6.0/esptool.py --chip esp32 --port /dev/ttyUSB0 write_flash 0x10000 ESP8266WirelessPrintAsync_esp32_*.bin 80 | 81 | # Wireless 82 | python $HOME/.arduino15/packages/esp32/hardware/esp32/1.0.1/tools/espota.py -i 192.168.0.16 -p 3232 --auth= -f ESP8266WirelessPrintAsync_esp32_*.bin 83 | ``` 84 | 85 | After the initial flashing, you can upload new versions of this firmware from the web interface without any further tools. 86 | 87 | ## Initial WiFi Configuration 88 | Following the instructions in https://github.com/alanswx/ESPAsyncWiFiManager/ : 89 | 90 | The first time the sketch is uploaded the ESP will enter in Access Point mode, so you have to open the wifi manager of your system and connect to wifi "AutoConnectAP", then open your browser and type http://192.168.4.1/, there you will see a menu, select "Configure WiFi", press scan and wait until the device scans available networks and select yours, enter the the password and click save. It will try to connect to your network, if it's successfull you will see a message on your 3D printer (or in a serial monitor if conected to your computer) with the new device IP, write down this IP if you wish to connect via browser. 91 | 92 | ## Wireless printing with Cura 93 | 94 | Cura 2.6 and later come with a bundled plugin which discovers OctoPrint instances using Zeroconf and enables printing directly to them. In newer versions of Cura, you need to install the [Cura OctoPrint Plugin](https://github.com/fieldOfView/Cura-OctoPrintPlugin) from the "Toolbox" menu. To use it, 95 | - In Cura, add a Printer matching the 3D printer you have connected to WirelessPrint 96 | - Select "Connect to OctoPrint" on the Manage Printers page 97 | - Select your OctoPrint instance from the list 98 | - Enter an API key (for now a random one is sufficient) 99 | - Click "Connect", then click "Close" 100 | From this point on, the print monitor should be functional and you should see a "Print with OctoPrint" button on the bottom of the sidebar. Use this button to print wirelessly. 101 | 102 | ## Wireless printing with PrusaSlicer 103 | 104 | Slic3r PE 1.36.0 discovers OctoPrint instances using Zeroconf and enables printing directly to them. No further software needs to be installed. To use it, 105 | - In Slic3r PE, add the corresponding profile for your printer 106 | - Select the "Printer Settings" tab 107 | - Under "OctoPrint upload", enter the IP address of your WirelessPrinting device (in the future, it may be discoverable by Bonjour) 108 | - Click "Test" 109 | From this point on, you should see a "Send to printer" button on the "Plater" tab. Use this button to print wirelessly. 110 | 111 | Later PrusaSlicer versions may require you to enter the IP address manually (bug?). 112 | 113 | ## Wireless printing using a browser or the command line 114 | 115 | To print, just open http://the-ip-address/ and upload a G-Code file using the form: 116 | 117 | ![Upload](https://cloud.githubusercontent.com/assets/2480569/23586936/fd0e3fa2-01a0-11e7-9d83-dc4e7d031f30.png) 118 | 119 | Ycan also print from the command line using curl: 120 | 121 | ``` 122 | curl -F "file=@/path/to/some.gcode" -F "print=true" http://the-ip-address/print 123 | ``` 124 | -------------------------------------------------------------------------------- /WirelessPrinting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/probonopd/WirelessPrinting/1f4a381489fd458cd358db380c1d058e162b4706/WirelessPrinting.png -------------------------------------------------------------------------------- /platformio.ini: -------------------------------------------------------------------------------- 1 | ; PlatformIO Project Configuration File 2 | ; 3 | ; Build options: build flags, source filter 4 | ; Upload options: custom upload port, speed and extra flags 5 | ; Library options: dependencies, extra library storages 6 | ; Advanced options: extra scripting 7 | ; 8 | ; Please visit documentation for the other options and examples 9 | ; https://docs.platformio.org/page/projectconf.html 10 | 11 | [platformio] 12 | src_dir = ESP8266WirelessPrintAsync 13 | 14 | # common variables shared by the environments 15 | [env] 16 | framework = arduino 17 | lib_deps = 18 | https://github.com/greiman/SdFat#3b79f38 19 | https://github.com/me-no-dev/ESPAsyncTCP#7e9ed22 20 | https://github.com/me-no-dev/ESPAsyncWebServer#95dedf7 21 | https://github.com/ayushsharma82/AsyncElegantOTA#4b3528c 22 | https://github.com/alanswx/ESPAsyncWiFiManager#1c02154 23 | https://github.com/bblanchon/ArduinoJson#3df4efd 24 | https://github.com/Makuna/NeoPixelBus#9619fef 25 | build_flags = -DLOG_LOCAL_LEVEL=ESP_LOG_NONE 26 | 27 | # base environments 28 | # can be extended by board-specific environments 29 | [base:esp8266] 30 | ; corresponds to https://github.com/platformio/platform-espressif8266/releases/tag/v2.0.0 31 | ; see https://github.com/esp8266/Arduino/releases/tag/2.5.0 32 | platform = espressif8266@2.0.0 33 | framework = ${env.framework} 34 | lib_deps = ${env.lib_deps} 35 | lib_ignore = AsyncTCP 36 | 37 | [base:esp32] 38 | ; corresponds to https://github.com/platformio/platform-espressif32/releases/tag/v1.8.0 39 | ; see https://github.com/espressif/arduino-esp32/releases/tag/1.0.2 40 | platform = espressif32@1.8.0 41 | framework = ${env.framework} 42 | lib_deps = 43 | ${env.lib_deps} 44 | https://github.com/bbx10/Hash_tng 45 | 46 | [env:nodemcuv2] 47 | board = nodemcuv2 48 | extends = base:esp8266 49 | 50 | [env:d1_mini] 51 | board = d1_mini 52 | extends = base:esp8266 53 | 54 | # esp32dev works for the majority of ESP32 based dev boards 55 | # there are more specific board configurations available, feel free to send PRs adding new ones 56 | [env:esp32dev] 57 | board = esp32dev 58 | extends = base:esp32 59 | --------------------------------------------------------------------------------