
New features: The layer height comment in gcode files produced by Matter Control is now recognised M101 and M103 (firmware retraction commands generated by Simplify3D) are now recognised G20 bed probing now prints the mean height error and RMS deviation to 3 decimal places Bug fixes: G30 with no parameters now sets the machine position to the Z probe trigger height instead of the user position. This means that running G28 (home) and G29 (bed probe) alternately no longer causes the bed height map to creep up or down in average height when Z homing uses the Z probe. However, it means that the Z height displayed after a G30 command is no longer always the same as the Z probe trigger height. In firmware 1.17 the speed change command by the M220 command got delayed until the next G1 or G0 command with an F parameter was read. In firmware 1.16 and 1.17 the acceleration used in G11 un-retraction commands was sometimes incorrectly reduced if pressure advance was configured on a different extruder. In firmware 1.16 and 1.17 when pressure advance was configured, the extruder acceleration limit implied by pressure advance used the pressure advance value for the wrong extruder. Firmware retraction did not work if additional axes had been configured e.g. for an IDEX printer. G29 commands using the P parameter with an upper case M in the filename were recognised as M commands instead
1165 lines
35 KiB
C++
1165 lines
35 KiB
C++
/****************************************************************************************************
|
|
|
|
RepRapFirmware - PrintMonitor
|
|
|
|
This class provides methods to obtain print end-time estimations and file information from generated
|
|
G-Code files, which may be reported to auxiliary devices and to the web interface using status responses.
|
|
|
|
-----------------------------------------------------------------------------------------------------
|
|
|
|
Version 0.1
|
|
|
|
Created on: Feb 24, 2015
|
|
|
|
Christian Hammacher
|
|
|
|
Licence: GPL
|
|
|
|
****************************************************************************************************/
|
|
|
|
#include "PrintMonitor.h"
|
|
|
|
#include "GCodes/GCodes.h"
|
|
#include "Heating/Heat.h"
|
|
#include "Movement/Move.h"
|
|
#include "Platform.h"
|
|
#include "RepRap.h"
|
|
|
|
PrintMonitor::PrintMonitor(Platform *p, GCodes *gc) : platform(p), gCodes(gc), isPrinting(false),
|
|
printStartTime(0), pauseStartTime(0.0), totalPauseTime(0.0), heatingUp(false), currentLayer(0), warmUpDuration(0.0),
|
|
firstLayerDuration(0.0), firstLayerFilament(0.0), firstLayerProgress(0.0), lastLayerChangeTime(0.0),
|
|
lastLayerFilament(0.0), lastLayerZ(0.0), numLayerSamples(0), layerEstimatedTimeLeft(0.0), parseState(notParsing),
|
|
fileBeingParsed(nullptr), fileOverlapLength(0), printingFileParsed(false), accumulatedParseTime(0),
|
|
accumulatedReadTime(0), accumulatedSeekTime(0)
|
|
{
|
|
filenameBeingPrinted[0] = 0;
|
|
}
|
|
|
|
void PrintMonitor::Init()
|
|
{
|
|
longWait = platform->Time();
|
|
lastUpdateTime = millis();
|
|
}
|
|
|
|
void PrintMonitor::Spin()
|
|
{
|
|
// File information about the file being printed must be available before layer estimations can be made
|
|
if (filenameBeingPrinted[0] != 0 && !printingFileParsed)
|
|
{
|
|
printingFileParsed = GetFileInfo(platform->GetGCodeDir(), filenameBeingPrinted, printingFileInfo);
|
|
if (!printingFileParsed)
|
|
{
|
|
platform->ClassReport(longWait);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Don't do any updates if the print has been paused
|
|
if (!gCodes->IsRunning())
|
|
{
|
|
if (pauseStartTime == 0.0)
|
|
{
|
|
pauseStartTime = platform->Time();
|
|
}
|
|
platform->ClassReport(longWait);
|
|
return;
|
|
}
|
|
|
|
// Otherwise collect some stats after a certain period of time
|
|
uint32_t now = millis();
|
|
if (IsPrinting()
|
|
#if SUPPORT_ROLAND
|
|
&& !reprap.GetRoland()->Active()
|
|
#endif
|
|
&& now - lastUpdateTime > PRINTMONITOR_UPDATE_INTERVAL)
|
|
{
|
|
// Adjust the actual print time if the print was paused before
|
|
if (pauseStartTime != 0.0)
|
|
{
|
|
totalPauseTime += platform->Time() - pauseStartTime;
|
|
pauseStartTime = 0.0;
|
|
}
|
|
|
|
// Have we just started a print? See if we're heating up
|
|
if (currentLayer == 0)
|
|
{
|
|
// Check if there are any active heaters
|
|
bool nozzleAtHighTemperature = false;
|
|
for(int heater = 0; heater < HEATERS; heater++)
|
|
{
|
|
if (reprap.GetHeat()->GetStatus(heater) == Heat::HS_active && reprap.GetHeat()->GetActiveTemperature(heater) > TEMPERATURE_LOW_SO_DONT_CARE)
|
|
{
|
|
heatingUp = true;
|
|
|
|
// Check if this heater is assigned to a tool and if it has reached its set temperature yet
|
|
if (reprap.IsHeaterAssignedToTool(heater))
|
|
{
|
|
if (!reprap.GetHeat()->HeaterAtSetTemperature(heater, false))
|
|
{
|
|
nozzleAtHighTemperature = false;
|
|
break;
|
|
}
|
|
nozzleAtHighTemperature = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Yes - do we have live movement?
|
|
if (nozzleAtHighTemperature && !reprap.GetMove()->NoLiveMovement())
|
|
{
|
|
// Yes - we're actually starting the print
|
|
WarmUpComplete();
|
|
currentLayer = 1;
|
|
}
|
|
}
|
|
// Print is in progress and filament is being extruded
|
|
else if (!gCodes->DoingFileMacro() && reprap.GetMove()->IsExtruding())
|
|
{
|
|
float liveCoordinates[DRIVES];
|
|
reprap.GetMove()->LiveCoordinates(liveCoordinates, reprap.GetCurrentXAxes());
|
|
|
|
// See if we need to determine the first layer height (usually smaller than the nozzle diameter)
|
|
if (printingFileInfo.firstLayerHeight == 0.0)
|
|
{
|
|
if (liveCoordinates[Z_AXIS] < platform->GetNozzleDiameter() * 1.5)
|
|
{
|
|
// This shouldn't be needed because we parse the first layer height anyway, but it won't harm
|
|
printingFileInfo.firstLayerHeight = liveCoordinates[Z_AXIS];
|
|
}
|
|
}
|
|
// Check if we've finished the first layer
|
|
else if (currentLayer == 1)
|
|
{
|
|
if (liveCoordinates[Z_AXIS] > printingFileInfo.firstLayerHeight + LAYER_HEIGHT_TOLERANCE)
|
|
{
|
|
FirstLayerComplete();
|
|
currentLayer++;
|
|
|
|
lastLayerZ = liveCoordinates[Z_AXIS];
|
|
lastLayerChangeTime = GetPrintDuration();
|
|
}
|
|
}
|
|
// Check for following layer changes
|
|
else if (liveCoordinates[Z_AXIS] > lastLayerZ + LAYER_HEIGHT_TOLERANCE)
|
|
{
|
|
LayerComplete();
|
|
currentLayer++;
|
|
|
|
lastLayerZ = liveCoordinates[Z_AXIS];
|
|
lastLayerChangeTime = GetPrintDuration();
|
|
}
|
|
}
|
|
lastUpdateTime = now;
|
|
}
|
|
platform->ClassReport(longWait);
|
|
}
|
|
|
|
float PrintMonitor::GetWarmUpDuration() const
|
|
{
|
|
if (currentLayer > 0)
|
|
{
|
|
return warmUpDuration;
|
|
}
|
|
return heatingUp ? GetPrintDuration() : 0.0;
|
|
}
|
|
|
|
// Notifies this class that a file has been set for printing
|
|
void PrintMonitor::StartingPrint(const char* filename)
|
|
{
|
|
printingFileParsed = GetFileInfo(platform->GetGCodeDir(), filename, printingFileInfo);
|
|
strncpy(filenameBeingPrinted, filename, ARRAY_SIZE(filenameBeingPrinted));
|
|
filenameBeingPrinted[ARRAY_UPB(filenameBeingPrinted)] = 0;
|
|
}
|
|
|
|
// Tell this class that the file set for printing is now actually processed
|
|
void PrintMonitor::StartedPrint()
|
|
{
|
|
isPrinting = true;
|
|
printStartTime = platform->Time();
|
|
}
|
|
|
|
// This is called as soon as the heaters are at temperature and the actual print has started
|
|
void PrintMonitor::WarmUpComplete()
|
|
{
|
|
heatingUp = false;
|
|
warmUpDuration = GetPrintDuration();
|
|
}
|
|
|
|
// Called when the first layer has been finished
|
|
void PrintMonitor::FirstLayerComplete()
|
|
{
|
|
firstLayerFilament = gCodes->GetTotalRawExtrusion();
|
|
firstLayerDuration = GetPrintDuration() - warmUpDuration;
|
|
firstLayerProgress = gCodes->FractionOfFilePrinted();
|
|
|
|
// Update layer-based estimation time (if the object and layer heights are known)
|
|
// This won't be very accurate, but at least something can be sent the web interface and to PanelDue
|
|
if (printingFileInfo.objectHeight > 0.0 && printingFileInfo.layerHeight > 0.0)
|
|
{
|
|
unsigned int layersToPrint = round((printingFileInfo.objectHeight - printingFileInfo.firstLayerHeight) / printingFileInfo.layerHeight) + 1;
|
|
layerEstimatedTimeLeft = firstLayerDuration * FIRST_LAYER_SPEED_FACTOR * (layersToPrint - 1);
|
|
}
|
|
}
|
|
|
|
// This is called whenever a layer greater than 2 has been finished
|
|
void PrintMonitor::LayerComplete()
|
|
{
|
|
// Record a new set of layer, filament and file stats
|
|
const float extrRawTotal = gCodes->GetTotalRawExtrusion();
|
|
if (numLayerSamples < MAX_LAYER_SAMPLES)
|
|
{
|
|
if (numLayerSamples == 0)
|
|
{
|
|
filamentUsagePerLayer[numLayerSamples] = extrRawTotal - firstLayerFilament;
|
|
layerDurations[numLayerSamples] = GetPrintDuration() - warmUpDuration;
|
|
}
|
|
else
|
|
{
|
|
filamentUsagePerLayer[numLayerSamples] = extrRawTotal - lastLayerFilament;
|
|
layerDurations[numLayerSamples] = GetPrintDuration() - lastLayerChangeTime;
|
|
}
|
|
fileProgressPerLayer[numLayerSamples] = gCodes->FractionOfFilePrinted();
|
|
numLayerSamples++;
|
|
}
|
|
else
|
|
{
|
|
for(size_t i = 1; i < MAX_LAYER_SAMPLES; i++)
|
|
{
|
|
layerDurations[i - 1] = layerDurations[i];
|
|
filamentUsagePerLayer[i - 1] = filamentUsagePerLayer[i];
|
|
fileProgressPerLayer[i - 1] = fileProgressPerLayer[i];
|
|
}
|
|
|
|
layerDurations[MAX_LAYER_SAMPLES - 1] = GetPrintDuration() - lastLayerChangeTime;
|
|
filamentUsagePerLayer[MAX_LAYER_SAMPLES - 1] = extrRawTotal - lastLayerFilament;
|
|
fileProgressPerLayer[MAX_LAYER_SAMPLES - 1] = gCodes->FractionOfFilePrinted();
|
|
}
|
|
lastLayerFilament = extrRawTotal;
|
|
|
|
// Update layer-based estimation time (if the object and layer heights are known)
|
|
if (printingFileInfo.objectHeight > 0.0 && printingFileInfo.layerHeight > 0.0)
|
|
{
|
|
// Calculate the average layer time and include the first layer if possible
|
|
float avgLayerTime = (numLayerSamples < MAX_LAYER_SAMPLES)
|
|
? firstLayerDuration * FIRST_LAYER_SPEED_FACTOR
|
|
: 0.0;
|
|
for(size_t layer = 0; layer < numLayerSamples; layer++)
|
|
{
|
|
avgLayerTime += layerDurations[layer];
|
|
}
|
|
avgLayerTime /= (numLayerSamples < MAX_LAYER_SAMPLES) ? numLayerSamples + 1 : numLayerSamples;
|
|
|
|
// Estimate the layer-based time left
|
|
unsigned int totalLayers;
|
|
totalLayers = round((printingFileInfo.objectHeight - printingFileInfo.firstLayerHeight) / printingFileInfo.layerHeight) + 1;
|
|
if (currentLayer < totalLayers)
|
|
{
|
|
// Current layer is within reasonable boundaries, so an estimation can be made
|
|
layerEstimatedTimeLeft = avgLayerTime * (totalLayers - currentLayer);
|
|
}
|
|
else
|
|
{
|
|
// Current layer is higher than the maximum number of layers. Assume the print has almost finished
|
|
layerEstimatedTimeLeft = 0.1;
|
|
}
|
|
}
|
|
}
|
|
|
|
void PrintMonitor::StoppedPrint()
|
|
{
|
|
isPrinting = heatingUp = printingFileParsed = false;
|
|
currentLayer = numLayerSamples = 0;
|
|
pauseStartTime = totalPauseTime = 0.0;
|
|
firstLayerDuration = firstLayerFilament = firstLayerProgress = 0.0;
|
|
layerEstimatedTimeLeft = printStartTime = warmUpDuration = 0.0;
|
|
lastLayerChangeTime = lastLayerFilament = lastLayerZ = 0.0;
|
|
}
|
|
|
|
bool PrintMonitor::GetFileInfo(const char *directory, const char *fileName, GCodeFileInfo& info)
|
|
{
|
|
if (parseState == notParsing)
|
|
{
|
|
// See if we can access the file
|
|
// Webserver may call rr_fileinfo for a directory, check this case here
|
|
if (reprap.GetPlatform()->GetMassStorage()->DirectoryExists(directory, fileName))
|
|
{
|
|
info.isValid = false;
|
|
return true;
|
|
}
|
|
|
|
fileBeingParsed = platform->GetFileStore(directory, fileName, false);
|
|
if (fileBeingParsed == nullptr)
|
|
{
|
|
// Something went wrong - we cannot open it
|
|
info.isValid = false;
|
|
return true;
|
|
}
|
|
|
|
// File has been opened, let's start now
|
|
strncpy(filenameBeingParsed, fileName, ARRAY_SIZE(filenameBeingParsed));
|
|
filenameBeingParsed[ARRAY_UPB(filenameBeingParsed)] = 0;
|
|
fileOverlapLength = 0;
|
|
|
|
// Set up the info struct
|
|
parsedFileInfo.isValid = true;
|
|
parsedFileInfo.fileSize = fileBeingParsed->Length();
|
|
parsedFileInfo.lastModifiedTime = reprap.GetPlatform()->GetMassStorage()->GetLastModifiedTime(directory, fileName);
|
|
parsedFileInfo.firstLayerHeight = 0.0;
|
|
parsedFileInfo.objectHeight = 0.0;
|
|
parsedFileInfo.layerHeight = 0.0;
|
|
parsedFileInfo.numFilaments = 0;
|
|
parsedFileInfo.generatedBy[0] = 0;
|
|
for(size_t extr = 0; extr < DRIVES - MIN_AXES; extr++)
|
|
{
|
|
parsedFileInfo.filamentNeeded[extr] = 0.0;
|
|
}
|
|
|
|
// Record some debug values here
|
|
if (reprap.Debug(modulePrintMonitor))
|
|
{
|
|
accumulatedReadTime = accumulatedParseTime = 0;
|
|
platform->MessageF(HOST_MESSAGE, "-- Parsing file %s --\n", fileName);
|
|
}
|
|
|
|
// If the file is empty or not a G-Code file, we don't need to parse anything
|
|
if (fileBeingParsed->Length() == 0 || (!StringEndsWith(fileName, ".gcode") && !StringEndsWith(fileName, ".g")
|
|
&& !StringEndsWith(fileName, ".gco") && !StringEndsWith(fileName, ".gc")))
|
|
{
|
|
fileBeingParsed->Close();
|
|
info = parsedFileInfo;
|
|
return true;
|
|
}
|
|
parseState = parsingHeader;
|
|
}
|
|
else if (!StringEquals(fileName, filenameBeingParsed))
|
|
{
|
|
// We are already parsing a different file. Try again later.
|
|
return false;
|
|
}
|
|
|
|
// Getting file information take a few runs. Speed it up when we are not printing by calling it several times.
|
|
const uint32_t loopStartTime = millis();
|
|
do
|
|
{
|
|
uint32_t buf32[(GCODE_READ_SIZE + GCODE_OVERLAP_SIZE + 3)/4 + 1]; // buffer should be 32-bit aligned for HSMCI (need the +1 so we can add a null terminator)
|
|
char* const buf = reinterpret_cast<char*>(buf32);
|
|
size_t sizeToRead, sizeToScan; // number of bytes we want to read and scan in this go
|
|
|
|
if (parseState == parsingHeader)
|
|
{
|
|
bool headerInfoComplete = true;
|
|
|
|
// Read a chunk from the header. On the first run only process GCODE_READ_SIZE bytes, but use overlap next times.
|
|
sizeToRead = (size_t)min<FilePosition>(fileBeingParsed->Length() - fileBeingParsed->Position(), GCODE_READ_SIZE);
|
|
if (fileOverlapLength > 0)
|
|
{
|
|
memcpy(buf, fileOverlap, fileOverlapLength);
|
|
sizeToScan = sizeToRead + fileOverlapLength;
|
|
}
|
|
else
|
|
{
|
|
sizeToScan = sizeToRead;
|
|
}
|
|
|
|
uint32_t startTime = millis();
|
|
const int nbytes = fileBeingParsed->Read(&buf[fileOverlapLength], sizeToRead);
|
|
if (nbytes != (int)sizeToRead)
|
|
{
|
|
platform->MessageF(HOST_MESSAGE, "Error: Failed to read header of G-Code file \"%s\"\n", fileName);
|
|
parseState = notParsing;
|
|
fileBeingParsed->Close();
|
|
info = parsedFileInfo;
|
|
return true;
|
|
}
|
|
buf[sizeToScan] = 0;
|
|
|
|
// Record performance data
|
|
uint32_t now = millis();
|
|
accumulatedReadTime += now - startTime;
|
|
startTime = now;
|
|
|
|
// Search for filament usage (Cura puts it at the beginning of a G-code file)
|
|
if (parsedFileInfo.numFilaments == 0)
|
|
{
|
|
parsedFileInfo.numFilaments = FindFilamentUsed(buf, sizeToScan, parsedFileInfo.filamentNeeded, DRIVES - reprap.GetGCodes()->GetNumAxes());
|
|
headerInfoComplete &= (parsedFileInfo.numFilaments != 0);
|
|
}
|
|
|
|
// Look for first layer height
|
|
if (parsedFileInfo.firstLayerHeight == 0.0)
|
|
{
|
|
headerInfoComplete &= FindFirstLayerHeight(buf, sizeToScan, parsedFileInfo.firstLayerHeight);
|
|
}
|
|
|
|
// Look for layer height
|
|
if (parsedFileInfo.layerHeight == 0.0)
|
|
{
|
|
headerInfoComplete &= FindLayerHeight(buf, sizeToScan, parsedFileInfo.layerHeight);
|
|
}
|
|
|
|
// Look for slicer program
|
|
if (parsedFileInfo.generatedBy[0] == 0)
|
|
{
|
|
// Slic3r and S3D
|
|
const char* generatedByString = "generated by ";
|
|
const char* introString = "";
|
|
const char* pos = strstr(buf, generatedByString);
|
|
if (pos != nullptr)
|
|
{
|
|
pos += strlen(generatedByString);
|
|
}
|
|
else
|
|
{
|
|
// KISSlicer
|
|
pos = strstr(buf, "; KISSlicer");
|
|
if (pos != nullptr)
|
|
{
|
|
pos += 2;
|
|
}
|
|
else
|
|
{
|
|
// Cura (old)
|
|
const char* slicedAtString = ";Sliced at: ";
|
|
pos = strstr(buf, slicedAtString);
|
|
if (pos != nullptr)
|
|
{
|
|
pos += strlen(slicedAtString);
|
|
introString = "Cura at ";
|
|
}
|
|
else
|
|
{
|
|
// Cura (new)
|
|
const char* generatedWithString = ";Generated with ";
|
|
pos = strstr(buf, generatedWithString);
|
|
if (pos != nullptr)
|
|
{
|
|
pos += strlen(generatedWithString);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pos != nullptr)
|
|
{
|
|
strcpy(parsedFileInfo.generatedBy, introString);
|
|
size_t i = strlen(introString);
|
|
while (i < ARRAY_SIZE(parsedFileInfo.generatedBy) - 1 && *pos >= ' ')
|
|
{
|
|
parsedFileInfo.generatedBy[i++] = *pos++;
|
|
}
|
|
parsedFileInfo.generatedBy[i] = 0;
|
|
}
|
|
}
|
|
headerInfoComplete &= (parsedFileInfo.generatedBy[0] != 0);
|
|
|
|
// Keep track of the time stats
|
|
accumulatedParseTime += millis() - startTime;
|
|
|
|
// Can we proceed to the footer? Don't scan more than the first 4KB of the file
|
|
FilePosition pos = fileBeingParsed->Position();
|
|
if (headerInfoComplete || pos >= GCODE_HEADER_SIZE || pos == fileBeingParsed->Length())
|
|
{
|
|
// Yes - see if we need to output some debug info
|
|
if (reprap.Debug(modulePrintMonitor))
|
|
{
|
|
platform->MessageF(HOST_MESSAGE, "Header complete, processed %lu bytes, read time %.3fs, parse time %.3fs\n",
|
|
fileBeingParsed->Position(), (float)accumulatedReadTime/1000.0, (float)accumulatedParseTime/1000.0);
|
|
}
|
|
|
|
// Go to the last chunk and proceed from there on
|
|
startTime = millis();
|
|
const FilePosition seekFromEnd = ((fileBeingParsed->Length() - 1) % GCODE_READ_SIZE) + 1;
|
|
fileBeingParsed->Seek(fileBeingParsed->Length() - seekFromEnd);
|
|
accumulatedSeekTime = millis() - startTime;
|
|
accumulatedReadTime = accumulatedParseTime = 0;
|
|
fileOverlapLength = 0;
|
|
parseState = parsingFooter;
|
|
}
|
|
else
|
|
{
|
|
// No - copy the last chunk of the buffer for overlapping search
|
|
fileOverlapLength = min<size_t>(sizeToRead, GCODE_OVERLAP_SIZE);
|
|
memcpy(fileOverlap, &buf[sizeToRead - fileOverlapLength], fileOverlapLength);
|
|
}
|
|
}
|
|
|
|
if (parseState == parsingFooter)
|
|
{
|
|
// Processing the footer. See how many bytes we need to read and if we can reuse the overlap
|
|
FilePosition pos = fileBeingParsed->Position();
|
|
sizeToRead = (size_t)min<FilePosition>(fileBeingParsed->Length() - pos, GCODE_READ_SIZE);
|
|
if (fileOverlapLength > 0)
|
|
{
|
|
memcpy(&buf[sizeToRead], fileOverlap, fileOverlapLength);
|
|
sizeToScan = sizeToRead + fileOverlapLength;
|
|
}
|
|
else
|
|
{
|
|
sizeToScan = sizeToRead;
|
|
}
|
|
|
|
// Read another chunk from the footer
|
|
uint32_t startTime = millis();
|
|
int nbytes = fileBeingParsed->Read(buf, sizeToRead);
|
|
if (nbytes != (int)sizeToRead)
|
|
{
|
|
platform->MessageF(HOST_MESSAGE, "Error: Failed to read footer from G-Code file \"%s\"\n", fileName);
|
|
parseState = notParsing;
|
|
fileBeingParsed->Close();
|
|
info = parsedFileInfo;
|
|
return true;
|
|
}
|
|
buf[sizeToScan] = 0;
|
|
|
|
// Record performance data
|
|
uint32_t now = millis();
|
|
accumulatedReadTime += now - startTime;
|
|
startTime = now;
|
|
|
|
bool footerInfoComplete = true;
|
|
|
|
// Search for filament used
|
|
if (parsedFileInfo.numFilaments == 0)
|
|
{
|
|
parsedFileInfo.numFilaments = FindFilamentUsed(buf, sizeToScan, parsedFileInfo.filamentNeeded, DRIVES - reprap.GetGCodes()->GetNumAxes());
|
|
if (parsedFileInfo.numFilaments == 0)
|
|
{
|
|
footerInfoComplete = false;
|
|
}
|
|
}
|
|
|
|
// Search for layer height
|
|
if (parsedFileInfo.layerHeight == 0.0)
|
|
{
|
|
if (!FindLayerHeight(buf, sizeToScan, parsedFileInfo.layerHeight))
|
|
{
|
|
footerInfoComplete = false;
|
|
}
|
|
}
|
|
|
|
// Search for object height
|
|
if (parsedFileInfo.objectHeight == 0.0)
|
|
{
|
|
if (!FindHeight(buf, sizeToScan, parsedFileInfo.objectHeight))
|
|
{
|
|
footerInfoComplete = false;
|
|
}
|
|
}
|
|
|
|
// Keep track of the time stats
|
|
accumulatedParseTime += millis() - startTime;
|
|
|
|
// If we've collected all details, scanned the last 192K of the file or if we cannot go any further, stop here.
|
|
if (footerInfoComplete || pos == 0 || fileBeingParsed->Length() - pos >= GCODE_FOOTER_SIZE)
|
|
{
|
|
if (reprap.Debug(modulePrintMonitor))
|
|
{
|
|
platform->MessageF(HOST_MESSAGE, "Footer complete, processed %lu bytes, read time %.3fs, parse time %.3fs, seek time %.3fs\n",
|
|
fileBeingParsed->Length() - fileBeingParsed->Position() + GCODE_READ_SIZE,
|
|
(float)accumulatedReadTime/1000.0, (float)accumulatedParseTime/1000.0, (float)accumulatedSeekTime/1000.0);
|
|
}
|
|
parseState = notParsing;
|
|
fileBeingParsed->Close();
|
|
info = parsedFileInfo;
|
|
return true;
|
|
}
|
|
|
|
// Else go back further
|
|
startTime = millis();
|
|
size_t seekOffset = (size_t)min<FilePosition>(pos, GCODE_READ_SIZE);
|
|
if (!fileBeingParsed->Seek(pos - seekOffset))
|
|
{
|
|
platform->Message(HOST_MESSAGE, "Error: Could not seek from end of file!\n");
|
|
parseState = notParsing;
|
|
fileBeingParsed->Close();
|
|
info = parsedFileInfo;
|
|
return true;
|
|
}
|
|
accumulatedSeekTime += millis() - startTime;
|
|
|
|
fileOverlapLength = (size_t)min<FilePosition>(sizeToScan, GCODE_OVERLAP_SIZE);
|
|
memcpy(fileOverlap, buf, fileOverlapLength);
|
|
}
|
|
} while (!isPrinting && millis() - loopStartTime < MAX_FILEINFO_PROCESS_TIME);
|
|
return false;
|
|
}
|
|
|
|
// Get information for the specified file, or the currently printing file, in JSON format
|
|
bool PrintMonitor::GetFileInfoResponse(const char *filename, OutputBuffer *&response)
|
|
{
|
|
// Poll file info for a specific file
|
|
if (filename != nullptr && filename[0] != 0)
|
|
{
|
|
GCodeFileInfo info;
|
|
if (!GetFileInfo(FS_PREFIX, filename, info))
|
|
{
|
|
// This may take a few runs...
|
|
return false;
|
|
}
|
|
|
|
if (info.isValid)
|
|
{
|
|
if (!OutputBuffer::Allocate(response))
|
|
{
|
|
// Should never happen
|
|
return false;
|
|
}
|
|
|
|
response->printf("{\"err\":0,\"size\":%lu,",info.fileSize);
|
|
const struct tm * const timeInfo = gmtime(&info.lastModifiedTime);
|
|
if (timeInfo->tm_year > /*19*/80)
|
|
{
|
|
response->catf("\"lastModified\":\"%04u-%02u-%02uT%02u:%02u:%02u\",",
|
|
timeInfo->tm_year + 1900, timeInfo->tm_mon + 1, timeInfo->tm_mday,
|
|
timeInfo->tm_hour, timeInfo->tm_min, timeInfo->tm_sec);
|
|
}
|
|
|
|
response->catf("\"height\":%.2f,\"firstLayerHeight\":%.2f,\"layerHeight\":%.2f,\"filament\":",
|
|
info.objectHeight, info.firstLayerHeight, info.layerHeight);
|
|
char ch = '[';
|
|
if (info.numFilaments == 0)
|
|
{
|
|
response->cat(ch);
|
|
}
|
|
else
|
|
{
|
|
for(size_t i = 0; i < info.numFilaments; ++i)
|
|
{
|
|
response->catf("%c%.1f", ch, info.filamentNeeded[i]);
|
|
ch = ',';
|
|
}
|
|
}
|
|
response->cat("],\"generatedBy\":");
|
|
response->EncodeString(info.generatedBy, ARRAY_SIZE(info.generatedBy), false);
|
|
response->cat("}");
|
|
}
|
|
else
|
|
{
|
|
if (!OutputBuffer::Allocate(response))
|
|
{
|
|
// Should never happen
|
|
return false;
|
|
}
|
|
|
|
response->copy("{\"err\":1}");
|
|
}
|
|
}
|
|
else if (IsPrinting())
|
|
{
|
|
// If the file being printed hasn't been processed yet or if we
|
|
// cannot write the response, try again later
|
|
if (!printingFileParsed || !OutputBuffer::Allocate(response))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Poll file info about a file currently being printed
|
|
response->printf("{\"err\":0,\"size\":%lu,\"height\":%.2f,\"firstLayerHeight\":%.2f,\"layerHeight\":%.2f,\"filament\":",
|
|
printingFileInfo.fileSize, printingFileInfo.objectHeight, printingFileInfo.firstLayerHeight, printingFileInfo.layerHeight);
|
|
char ch = '[';
|
|
if (printingFileInfo.numFilaments == 0)
|
|
{
|
|
response->cat(ch);
|
|
}
|
|
else
|
|
{
|
|
for (size_t i = 0; i < printingFileInfo.numFilaments; ++i)
|
|
{
|
|
response->catf("%c%.1f", ch, printingFileInfo.filamentNeeded[i]);
|
|
ch = ',';
|
|
}
|
|
}
|
|
response->cat("],\"generatedBy\":");
|
|
response->EncodeString(printingFileInfo.generatedBy, ARRAY_SIZE(printingFileInfo.generatedBy), false);
|
|
response->catf(",\"printDuration\":%d,\"fileName\":", (int)GetPrintDuration());
|
|
response->EncodeString(filenameBeingPrinted, ARRAY_SIZE(filenameBeingPrinted), false);
|
|
response->cat('}');
|
|
}
|
|
else
|
|
{
|
|
if (!OutputBuffer::Allocate(response))
|
|
{
|
|
// Should never happen
|
|
return false;
|
|
}
|
|
|
|
response->copy("{\"err\":1}");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// May be called from ISR
|
|
void PrintMonitor::StopParsing(const char *filename)
|
|
{
|
|
if (parseState != notParsing && StringEquals(filenameBeingParsed, filename))
|
|
{
|
|
if (filenameBeingPrinted[0] != 0 && !printingFileParsed)
|
|
{
|
|
// If this is the file we're parsing for internal purposes, don't bother with this request
|
|
return;
|
|
}
|
|
|
|
parseState = notParsing;
|
|
fileBeingParsed->Close();
|
|
}
|
|
}
|
|
|
|
// Estimate the print time left in seconds on a preset estimation method
|
|
float PrintMonitor::EstimateTimeLeft(PrintEstimationMethod method) const
|
|
{
|
|
// We can't provide an estimation if we don't have any information about the file
|
|
if (!printingFileParsed)
|
|
{
|
|
return 0.0;
|
|
}
|
|
|
|
// How long have we been printing continuously?
|
|
float realPrintDuration = GetPrintDuration() - warmUpDuration;
|
|
|
|
switch (method)
|
|
{
|
|
case fileBased:
|
|
{
|
|
// Can we provide an estimation at all?
|
|
const float fractionPrinted = gCodes->FractionOfFilePrinted();
|
|
if (fractionPrinted < ESTIMATION_MIN_FILE_USAGE || heatingUp)
|
|
{
|
|
// No, we haven't printed enough of the file yet. We can't provide an estimation at this moment
|
|
return 0.0;
|
|
}
|
|
if (fractionPrinted == 1.0)
|
|
{
|
|
// No, but the file has been processed entirely. It won't take long until the print finishes
|
|
return 0.1;
|
|
}
|
|
|
|
// See how long it takes per progress
|
|
float duration, fractionPrintedInLayers;
|
|
if (numLayerSamples == 0)
|
|
{
|
|
duration = firstLayerDuration;
|
|
fractionPrintedInLayers = firstLayerProgress;
|
|
}
|
|
else if (numLayerSamples == 1)
|
|
{
|
|
duration = layerDurations[0];
|
|
fractionPrintedInLayers = fileProgressPerLayer[0] - firstLayerProgress;
|
|
}
|
|
else if (numLayerSamples > 1)
|
|
{
|
|
duration = 0.0;
|
|
for(size_t sample = 1; sample < numLayerSamples; sample++)
|
|
{
|
|
duration += layerDurations[sample];
|
|
}
|
|
fractionPrintedInLayers = fileProgressPerLayer[numLayerSamples - 1] - fileProgressPerLayer[0];
|
|
}
|
|
|
|
// Can we use these values?
|
|
if (fractionPrintedInLayers < ESTIMATION_MIN_FILE_USAGE)
|
|
{
|
|
// No - only provide a rough estimation
|
|
return max<float>(realPrintDuration * (1.0 / fractionPrinted) - realPrintDuration, 0.1);
|
|
}
|
|
|
|
// Yes...
|
|
return max<float>(duration * (1.0 - fractionPrinted) / fractionPrintedInLayers, 0.1);
|
|
}
|
|
|
|
case filamentBased:
|
|
{
|
|
// Need some file information, otherwise this method won't work
|
|
if (currentLayer == 0 || printingFileInfo.numFilaments == 0
|
|
#if SUPPORT_ROLAND
|
|
|| reprap.GetRoland()->Active()
|
|
#endif
|
|
)
|
|
{
|
|
return 0.0;
|
|
}
|
|
|
|
// Sum up the filament usage and the filament needed
|
|
float totalFilamentNeeded = 0.0;
|
|
const float extrRawTotal = gCodes->GetTotalRawExtrusion();
|
|
for(size_t extruder = 0; extruder < DRIVES - reprap.GetGCodes()->GetNumAxes(); extruder++)
|
|
{
|
|
totalFilamentNeeded += printingFileInfo.filamentNeeded[extruder];
|
|
}
|
|
|
|
// If we have a reasonable amount of filament extruded, calculate estimated times left
|
|
if (totalFilamentNeeded > 0.0 && extrRawTotal > totalFilamentNeeded * ESTIMATION_MIN_FILAMENT_USAGE)
|
|
{
|
|
// Do we have more total filament extruded than reported by the file
|
|
if (extrRawTotal >= totalFilamentNeeded)
|
|
{
|
|
// Yes - assume the print has almost finished
|
|
return 0.1;
|
|
}
|
|
|
|
// Get filament usage per layer
|
|
float filamentRate = 0.0;
|
|
if (numLayerSamples > 0)
|
|
{
|
|
for(size_t i = 0; i < numLayerSamples; i++)
|
|
{
|
|
filamentRate += filamentUsagePerLayer[i] / layerDurations[i];
|
|
}
|
|
filamentRate /= numLayerSamples;
|
|
}
|
|
else if (firstLayerDuration > 0.0)
|
|
{
|
|
filamentRate = firstLayerFilament / firstLayerDuration;
|
|
}
|
|
|
|
// Can we provide a good estimation?
|
|
if (filamentRate == 0.0)
|
|
{
|
|
// No - calculate time left based on the filament we have extruded so far
|
|
return realPrintDuration * (totalFilamentNeeded - extrRawTotal) / extrRawTotal;
|
|
}
|
|
|
|
return (totalFilamentNeeded - extrRawTotal) / filamentRate;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case layerBased:
|
|
// Layer-based estimations are made after each layer change, only reflect this value
|
|
if (layerEstimatedTimeLeft > 0.0)
|
|
{
|
|
float timeLeft = layerEstimatedTimeLeft - (GetPrintDuration() - lastLayerChangeTime);
|
|
return (timeLeft > 0.0) ? timeLeft : 0.1;
|
|
}
|
|
break;
|
|
}
|
|
|
|
return 0.0;
|
|
}
|
|
|
|
// Scan the buffer for a G1 Zxxx command. The buffer is null-terminated.
|
|
bool PrintMonitor::FindFirstLayerHeight(const char* buf, size_t len, float& height) const
|
|
{
|
|
if (len < 4)
|
|
{
|
|
// Don't start if the buffer is not big enough
|
|
return false;
|
|
}
|
|
height = 0.0;
|
|
|
|
//debugPrintf("Scanning %u bytes starting %.100s\n", len, buf);
|
|
bool inComment = false, inRelativeMode = false, foundHeight = false;
|
|
for(size_t i = 0; i < len - 4; i++)
|
|
{
|
|
if (buf[i] == ';')
|
|
{
|
|
inComment = true;
|
|
}
|
|
else if (inComment)
|
|
{
|
|
if (buf[i] == '\n')
|
|
{
|
|
inComment = false;
|
|
}
|
|
}
|
|
else if (buf[i] == 'G')
|
|
{
|
|
// See if we can switch back to absolute mode
|
|
if (inRelativeMode)
|
|
{
|
|
inRelativeMode = !(buf[i + 1] == '9' && buf[i + 2] == '0' && buf[i + 3] <= ' ');
|
|
}
|
|
// Ignore G0/G1 codes if in relative mode
|
|
else if (buf[i + 1] == '9' && buf[i + 2] == '1' && buf[i + 3] <= ' ')
|
|
{
|
|
inRelativeMode = true;
|
|
}
|
|
// Look for "G0/G1 ... Z#HEIGHT#" command
|
|
else if ((buf[i + 1] == '0' || buf[i + 1] == '1') && buf[i + 2] == ' ')
|
|
{
|
|
for(i += 3; i < len - 4; i++)
|
|
{
|
|
if (buf[i] == 'Z')
|
|
{
|
|
//debugPrintf("Found at offset %u text: %.100s\n", i, &buf[i + 1]);
|
|
float flHeight = strtod(&buf[i + 1], nullptr);
|
|
if ((height == 0.0 || flHeight < height) && (flHeight <= platform->GetNozzleDiameter() * 3.0))
|
|
{
|
|
height = flHeight; // Only report first Z height if it's somewhat reasonable
|
|
foundHeight = true;
|
|
// NB: Don't stop here, because some slicers generate two Z moves at the beginning
|
|
}
|
|
break;
|
|
}
|
|
else if (buf[i] == ';')
|
|
{
|
|
// Ignore comments
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return foundHeight;
|
|
}
|
|
|
|
// Scan the buffer for a G1 Zxxx command. The buffer is null-terminated.
|
|
// This parsing algorithm needs to be fast. The old one sometimes took 5 seconds or more to parse about 120K of data.
|
|
// To speed up parsing, we now parse forwards from the start of the buffer. This means we can't stop when we have found a G1 Z command,
|
|
// we have to look for a later G1 Z command in the buffer. But it is faster in the (common) case that we don't find a match in the buffer at all.
|
|
bool PrintMonitor::FindHeight(const char* buf, size_t len, float& height) const
|
|
{
|
|
bool foundHeight = false;
|
|
bool inRelativeMode = false;
|
|
for(;;)
|
|
{
|
|
// Skip to next newline
|
|
char c;
|
|
while (len >= 6 && (c = *buf) != '\r' && c != '\n')
|
|
{
|
|
++buf;
|
|
--len;
|
|
}
|
|
|
|
// Skip the newline and any leading spaces
|
|
do
|
|
{
|
|
++buf; // skip the newline
|
|
--len;
|
|
c = *buf;
|
|
} while (len >= 5 && (c == ' ' || c == '\t' || c == '\r' || c == '\n'));
|
|
|
|
if (len < 5)
|
|
{
|
|
break; // not enough characters left for a G1 Zx.x command
|
|
}
|
|
|
|
++buf; // move to 1 character beyond c
|
|
--len;
|
|
|
|
// In theory we should skip N and a line number here if they are present, but no slicers seem to generate line numbers
|
|
if (c == 'G')
|
|
{
|
|
if (inRelativeMode)
|
|
{
|
|
// We have seen a G91 in this buffer already, so we are only interested in G90 commands that switch back to absolute mode
|
|
if (buf[0] == '9' && buf[1] == '0' && (buf[2] < '0' || buf[2] > '9'))
|
|
{
|
|
// It's a G90 command so go back to absolute mode
|
|
inRelativeMode = false;
|
|
}
|
|
}
|
|
else if (*buf == '1' || *buf == '0')
|
|
{
|
|
// It could be a G0 or G1 command
|
|
++buf;
|
|
--len;
|
|
if (*buf < '0' || *buf > '9')
|
|
{
|
|
// It is a G0 or G1 command. See if it has a Z parameter.
|
|
while (len >= 4)
|
|
{
|
|
c = *buf;
|
|
if (c == 'Z')
|
|
{
|
|
const char* zpos = buf + 1;
|
|
// Check special case of this code ending with ";E" or "; E" - ignore such codes
|
|
while (len > 2 && *buf != '\n' && *buf != '\r' && *buf != ';')
|
|
{
|
|
++buf;
|
|
--len;
|
|
}
|
|
if ((len >= 2 && StringStartsWith(buf, ";E")) || (len >= 3 && StringStartsWith(buf, "; E")))
|
|
{
|
|
// Ignore this G1 Z command
|
|
}
|
|
else
|
|
{
|
|
height = strtod(zpos, nullptr);
|
|
foundHeight = true;
|
|
}
|
|
break; // carry on looking for a later G1 Z command
|
|
}
|
|
if (c == ';' || c == '\n' || c == '\r')
|
|
{
|
|
break; // no Z parameter
|
|
}
|
|
++buf;
|
|
--len;
|
|
}
|
|
}
|
|
}
|
|
else if (buf[0] == '9' && buf[1] == '1' && (buf[2] < '0' || buf[2] > '9'))
|
|
{
|
|
// It's a G91 command
|
|
inRelativeMode = true;
|
|
}
|
|
}
|
|
else if (c == ';')
|
|
{
|
|
static const char kisslicerHeightString[] = " END_LAYER_OBJECT z=";
|
|
if (len > 31 && StringStartsWith(buf, kisslicerHeightString))
|
|
{
|
|
height = strtod(buf + sizeof(kisslicerHeightString)/sizeof(char) - 1, nullptr);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return foundHeight;
|
|
}
|
|
|
|
// Scan the buffer for the layer height. The buffer is null-terminated.
|
|
bool PrintMonitor::FindLayerHeight(const char *buf, size_t len, float& layerHeight) const
|
|
{
|
|
static const char* const layerHeightStrings[] =
|
|
{
|
|
"layer_height", // slic3r
|
|
"Layer height", // Cura
|
|
"layerHeight", // S3D
|
|
"layer_thickness_mm", // Kisslicer
|
|
"layerThickness" // Matter Control
|
|
};
|
|
|
|
if (*buf != 0)
|
|
{
|
|
++buf; // make sure we can look back 1 character after we find a match
|
|
for (size_t i = 0; i < ARRAY_SIZE(layerHeightStrings); ++i) // search for each string in turn
|
|
{
|
|
const char *pos = buf;
|
|
for(;;) // loop until success or strstr returns null
|
|
{
|
|
pos = strstr(pos, layerHeightStrings[i]);
|
|
if (pos == nullptr)
|
|
{
|
|
break; // didn't find this string in the buffer, so try the next string
|
|
}
|
|
|
|
const char c = pos[-1]; // fetch the previous character
|
|
pos += strlen(layerHeightStrings[i]); // skip the string we matched
|
|
if (c == ' ' || c == ';' || c == '\t') // check we are not in the middle of a word
|
|
{
|
|
while (strchr(" \t=:,", *pos) != nullptr) // skip the possible separators
|
|
{
|
|
++pos;
|
|
}
|
|
char *tailPtr;
|
|
const float val = strtod(pos, &tailPtr);
|
|
if (tailPtr != pos) // if we found and converted a number
|
|
{
|
|
layerHeight = val;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Scan the buffer for the filament used. The buffer is null-terminated.
|
|
// Returns the number of filaments found.
|
|
unsigned int PrintMonitor::FindFilamentUsed(const char* buf, size_t len, float *filamentUsed, unsigned int maxFilaments) const
|
|
{
|
|
unsigned int filamentsFound = 0;
|
|
|
|
// Look for filament usage as generated by Slic3r and Cura
|
|
const char* filamentUsedStr = "ilament used"; // comment string used by slic3r and Cura, followed by filament used and "mm"
|
|
const char* p = buf;
|
|
while (filamentsFound < maxFilaments && (p = strstr(p, filamentUsedStr)) != nullptr)
|
|
{
|
|
p += strlen(filamentUsedStr);
|
|
while(strchr(" :=\t", *p) != nullptr)
|
|
{
|
|
++p; // this allows for " = " from default slic3r comment and ": " from default Cura comment
|
|
}
|
|
if (isDigit(*p))
|
|
{
|
|
char* q;
|
|
filamentUsed[filamentsFound] = strtod(p, &q);
|
|
if (*q == 'm' && *(q + 1) != 'm')
|
|
{
|
|
filamentUsed[filamentsFound] *= 1000.0; // Cura outputs filament used in metres not mm
|
|
}
|
|
++filamentsFound;
|
|
}
|
|
}
|
|
|
|
// Look for filament usage as generated by S3D
|
|
if (!filamentsFound)
|
|
{
|
|
const char *filamentLengthStr = "ilament length:"; // comment string used by S3D
|
|
p = buf;
|
|
while (filamentsFound < maxFilaments && (p = strstr(p, filamentLengthStr)) != nullptr)
|
|
{
|
|
p += strlen(filamentLengthStr);
|
|
while(strchr(" :=\t", *p) != nullptr)
|
|
{
|
|
++p; // this allows for " = " from default slic3r comment and ": " from default Cura comment
|
|
}
|
|
if (isDigit(*p))
|
|
{
|
|
filamentUsed[filamentsFound] = strtod(p, nullptr); // S3D reports filament usage in mm, no conversion needed
|
|
++filamentsFound;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Look for filament usage as generated by recent KISSlicer versions
|
|
if (!filamentsFound)
|
|
{
|
|
const char *filamentLengthStr = "; Ext ";
|
|
p = buf;
|
|
while (filamentsFound < maxFilaments && (p = strstr(p, filamentLengthStr)) != nullptr)
|
|
{
|
|
p += strlen(filamentLengthStr);
|
|
while(isdigit(*p))
|
|
{
|
|
++p;
|
|
}
|
|
while(strchr(" :=\t", *p) != nullptr)
|
|
{
|
|
++p;
|
|
}
|
|
|
|
if (isDigit(*p))
|
|
{
|
|
filamentUsed[filamentsFound] = strtod(p, nullptr);
|
|
++filamentsFound;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Special case: Old KISSlicer only generates the filament volume, so we need to calculate the length from it
|
|
if (!filamentsFound)
|
|
{
|
|
const char *filamentVolumeStr = "; Estimated Build Volume: ";
|
|
p = strstr(buf, filamentVolumeStr);
|
|
if (p != nullptr)
|
|
{
|
|
float filamentCMM = strtod(p + strlen(filamentVolumeStr), nullptr) * 1000.0;
|
|
filamentUsed[filamentsFound++] = filamentCMM / (PI * (platform->GetFilamentWidth() / 2.0) * (platform->GetFilamentWidth() / 2.0));
|
|
}
|
|
}
|
|
|
|
return filamentsFound;
|
|
}
|
|
|
|
// This returns the amount of time the machine has printed without interruptions (i.e. pauses)
|
|
float PrintMonitor::GetPrintDuration() const
|
|
{
|
|
if (!isPrinting)
|
|
{
|
|
// Can't provide a valid print duration if we don't know when it started
|
|
return 0.0;
|
|
}
|
|
|
|
float printDuration = platform->Time() - printStartTime - totalPauseTime;
|
|
if (pauseStartTime != 0.0)
|
|
{
|
|
// Take into account how long the machine has been paused
|
|
printDuration -= platform->Time() - pauseStartTime;
|
|
}
|
|
return printDuration;
|
|
}
|
|
|
|
// vim: ts=4:sw=4
|