/*
xdrv_50_filesystem.ino - unified file system for Tasmota
Copyright (C) 2020 Gerhard Mutz and Theo Arends
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
#ifdef USE_UFILESYS
// saves 80 bytes of flash, makes the UI cleaner for folders containing lots of files.
// disables recursive folder listing in file UI
//#define UFILESYS_NO_RECURSE_GUI
// Enables serving of static files on /fs/
// costs 1844 bytes of flash and 40 bytes of RAM
// probably not useful on esp8266, but useful on esp32
// You could serve a whole webapp from Tas itself.
//#define UFILESYS_STATIC_SERVING
/*********************************************************************************************\
This driver adds universal file system support for
- ESP8266 (sd card or littlefs on > 1 M devices with special linker file e.g. eagle.flash.4m2m.ld)
(makes no sense on 1M devices without sd card)
- ESP32 (sd card or littlefs or sfatfile system).
The sd card chip select is the standard SDCARD_CS or when not found SDCARD_CS_PIN and initializes
the FS System Pointer ufsp which can be used by all standard file system calls.
The only specific call is UfsInfo() which gets the total size (0) and free size (1).
A button is created in the setup section to show up the file directory to download and upload files
subdirectories are supported.
Supported commands:
ufs fs info
ufstype get filesytem type 0=none 1=SD 2=Flashfile
ufssize total size in kB
ufsfree free size in kB
\*********************************************************************************************/
#define XDRV_50 50
#define UFS_TNONE 0
#define UFS_TSDC 1
#define UFS_TFAT 2
#define UFS_TLFS 3
/*
// In tasmota.ino
#ifdef ESP8266
#include
#include
#ifdef USE_SDCARD
#include
#include
#endif // USE_SDCARD
#endif // ESP8266
#ifdef ESP32
#include
#ifdef USE_SDCARD
#include
#endif // USE_SDCARD
#include "FFat.h"
#include "FS.h"
#endif // ESP32
*/
// Global file system pointer
FS *ufsp;
// Flash file system pointer
FS *ffsp;
// Local pointer for file managment
FS *dfsp;
char ufs_path[48];
File ufs_upload_file;
uint8_t ufs_dir;
// 0 = None, 1 = SD, 2 = ffat, 3 = littlefs
uint8_t ufs_type;
uint8_t ffs_type;
struct {
char run_file[48];
int run_file_pos = -1;
bool run_file_mutex = 0;
bool download_busy;
} UfsData;
/*********************************************************************************************/
// Init flash file system
void UfsInitOnce(void) {
ufs_type = 0;
ffsp = 0;
ufs_dir = 0;
#ifdef ESP8266
ffsp = &LittleFS;
if (!LittleFS.begin()) {
ffsp = nullptr;
return;
}
#endif // ESP8266
#ifdef ESP32
// try lfs first
ffsp = &LittleFS;
if (!LittleFS.begin(true, "") && !LittleFS.begin(true, "", 5, "fs_1")) { // force empty mount point to make it the fallback FS
// ffat is second
ffsp = &FFat;
if (!FFat.begin(true, "")) {
ffsp = nullptr;
return;
}
ffs_type = UFS_TFAT;
ufs_type = ffs_type;
ufsp = ffsp;
dfsp = ffsp;
return;
}
#endif // ESP32
ffs_type = UFS_TLFS;
ufs_type = ffs_type;
ufsp = ffsp;
dfsp = ffsp;
}
// Called from tasmota.ino at restart. This inits flash file only
void UfsInit(void) {
UfsData.run_file_pos = -1;
UfsInitOnce();
if (ufs_type) {
AddLog(LOG_LEVEL_INFO, PSTR("UFS: FlashFS mounted with %d kB free"), UfsInfo(1, 0));
}
}
// simple put a zero at last '/'
// modifies input string
char *folderOnly(char *fname){
for (int i = strlen(fname)-1; i >= 0; i--){
if (fname[i] == '/'){
fname[i] = 0;
break;
}
fname[i] = 0;
}
if (!fname[0]){
fname[0] = '/';
fname[1] = 0;
}
return fname;
}
// returns everything after last '/' of whiole input if no '/'
char *fileOnly(char *fname){
char *cp = fname;
for (uint32_t cnt = strlen(fname); cnt >= 0; cnt--) {
if (fname[cnt] == '/') {
cp = &fname[cnt + 1];
break;
}
}
return cp;
}
#ifdef USE_SDCARD
void UfsCheckSDCardInit(void) {
// Try SPI mode first
// SPI mode requires SDCARD_CS to be configured
if (TasmotaGlobal.spi_enabled && PinUsed(GPIO_SDCARD_CS)) {
int8_t cs = Pin(GPIO_SDCARD_CS);
#ifdef ESP8266
SPI.begin();
#endif // ESP8266
#ifdef ESP32
SPI.begin(Pin(GPIO_SPI_CLK), Pin(GPIO_SPI_MISO), Pin(GPIO_SPI_MOSI), -1);
#endif // ESP32
if (SD.begin(cs)) {
#ifdef ESP8266
ufsp = &SDFS;
#endif // ESP8266
#ifdef ESP32
ufsp = &SD;
#endif // ESP32
ufs_type = UFS_TSDC;
dfsp = ufsp;
if (ffsp) {ufs_dir = 1;}
// make sd card the global filesystem
#ifdef ESP8266
// on esp8266 sdcard info takes several seconds !!!, so we ommit it here
AddLog(LOG_LEVEL_INFO, PSTR("UFS: SDCard mounted"));
#endif // ESP8266
#ifdef ESP32
AddLog(LOG_LEVEL_INFO, PSTR("UFS: SDCard mounted (SPI mode) with %d kB free"), UfsInfo(1, 0));
#endif // ESP32
}
}
#if defined(ESP32) && defined(SOC_SDMMC_HOST_SUPPORTED) // ESP32 and SDMMC supported (not Esp32C3)
// check if SDIO is configured
else if (PinUsed(GPIO_SDIO_CLK) && PinUsed(GPIO_SDIO_CMD) && PinUsed(GPIO_SDIO_D0)) {
int32_t sdio_cmd = Pin(GPIO_SDIO_CMD);
int32_t sdio_clk = Pin(GPIO_SDIO_CLK);
int32_t sdio_d0 = Pin(GPIO_SDIO_D0);
int32_t sdio_d1 = Pin(GPIO_SDIO_D1);
int32_t sdio_d2 = Pin(GPIO_SDIO_D2);
int32_t sdio_d3 = Pin(GPIO_SDIO_D3);
bool bit_4_mode = (sdio_d1 >= 0) && (sdio_d2 >= 0) && (sdio_d3 >= 0); // enable 4-bit mode if possible
if (bit_4_mode) {
// AddLog(LOG_LEVEL_DEBUG, "UFS: trying SDIO 4-bit clk=%i cmd=%i d0=%i d1=%i d2=%i d3=%i", sdio_clk, sdio_cmd, sdio_d0, sdio_d1, sdio_d2, sdio_d3);
SD_MMC.setPins(sdio_clk, sdio_cmd, sdio_d0, sdio_d1, sdio_d2, sdio_d3);
} else {
// AddLog(LOG_LEVEL_DEBUG, "UFS: trying SDIO 1-bit clk=%i cmd=%i d0=%i", sdio_clk, sdio_cmd, sdio_d0);
SD_MMC.setPins(sdio_clk, sdio_cmd, sdio_d0);
}
if (SD_MMC.begin("/sd", !bit_4_mode /*mode 1 bit*/, false /*format_if_failed*/)) { // mount under "/sd" to be consistent with SD SPI
ufsp = &SD_MMC;
ufs_type = UFS_TSDC;
dfsp = ufsp;
if (ffsp) {ufs_dir = 1;}
// make sd card the global filesystem
AddLog(LOG_LEVEL_INFO, PSTR("UFS: SDCard mounted (SDIO %i-bit) with %d kB free"), bit_4_mode ? 4 : 1, UfsInfo(1, 0));
}
}
#endif
}
#endif // USE_SDCARD
uint32_t UfsInfo(uint32_t sel, uint32_t type) {
uint64_t result = 0;
FS *ifsp = ufsp;
uint8_t itype = ufs_type;
if (type) {
ifsp = ffsp;
itype = ffs_type;
}
#ifdef ESP8266
FSInfo64 fsinfo;
#endif // ESP8266
switch (itype) {
case UFS_TSDC:
#ifdef USE_SDCARD
#ifdef ESP8266
ifsp->info64(fsinfo);
if (sel == 0) {
result = fsinfo.totalBytes;
} else {
result = (fsinfo.totalBytes - fsinfo.usedBytes);
}
#endif // ESP8266
#ifdef ESP32
if (sel == 0) {
result = SD.totalBytes();
} else {
result = (SD.totalBytes() - SD.usedBytes());
}
#endif // ESP32
#endif // USE_SDCARD
break;
case UFS_TLFS:
#ifdef ESP8266
ifsp->info64(fsinfo);
if (sel == 0) {
result = fsinfo.totalBytes;
} else {
result = (fsinfo.totalBytes - fsinfo.usedBytes);
}
#endif // ESP8266
#ifdef ESP32
if (sel == 0) {
result = LittleFS.totalBytes();
} else {
result = LittleFS.totalBytes() - LittleFS.usedBytes();
}
#endif // ESP32
break;
case UFS_TFAT:
#ifdef ESP32
if (sel == 0) {
result = FFat.totalBytes();
} else {
result = FFat.freeBytes();
}
#endif // ESP32
break;
}
return result / 1024;
}
uint32_t UfsSize(void) {
return UfsInfo(0, ufs_dir == 2 ? 1:0);
}
uint32_t UfsFree(void) {
return UfsInfo(1, ufs_dir == 2 ? 1:0);
}
#if USE_LONG_FILE_NAMES>0
#undef REJCMPL
#define REJCMPL 6
#else
#undef REJCMPL
#define REJCMPL 8
#endif
uint8_t UfsReject(char *name) {
char *lcp = strrchr(name,'/');
if (lcp) {
name = lcp + 1;
}
while (*name=='/') { name++; }
if (*name=='.') { return 1; }
if (!strncasecmp(name, "SPOTLI~1", REJCMPL)) { return 1; }
if (!strncasecmp(name, "TRASHE~1", REJCMPL)) { return 1; }
if (!strncasecmp(name, "FSEVEN~1", REJCMPL)) { return 1; }
if (!strncasecmp(name, "SYSTEM~1", REJCMPL)) { return 1; }
if (!strncasecmp(name, "System Volume", 13)) { return 1; }
return 0;
}
/*********************************************************************************************\
* Tfs low level functions
\*********************************************************************************************/
bool TfsFileExists(const char *fname){
if (!ffs_type) { return false; }
bool yes = ffsp->exists(fname);
if (!yes) {
AddLog(LOG_LEVEL_DEBUG, PSTR("TFS: File '%s' not found"), fname +1); // Skip leading slash
}
return yes;
}
size_t TfsFileSize(const char *fname){
if (!ffs_type) { return 0; }
File file = ffsp->open(fname, "r");
if (!file) { return 0; }
size_t flen = file.size();
file.close();
return flen;
}
bool TfsSaveFile(const char *fname, const uint8_t *buf, uint32_t len) {
if (!ffs_type) { return false; }
#ifdef USE_WEBCAM
WcInterrupt(0); // Stop stream if active to fix TG1WDT_SYS_RESET
#endif
bool result = false;
File file = ffsp->open(fname, "w");
if (!file) {
AddLog(LOG_LEVEL_INFO, PSTR("TFS: Save failed"));
} else {
// This will timeout on ESP32-webcam
// But now solved with WcInterrupt(0) in support_esp.ino
file.write(buf, len);
/*
// This will still timeout on ESP32-webcam when wcresolution 10
uint32_t count = len / 512;
uint32_t chunk = len / count;
for (uint32_t i = 0; i < count; i++) {
file.write(buf + (i * chunk), chunk);
// do actually wait a little to allow ESP32 tasks to tick
// fixes task timeout in ESP32Solo1 style unicore code and webcam.
delay(10);
OsWatchLoop();
}
uint32_t left = len % count;
if (left) {
file.write(buf + (count * chunk), left);
}
*/
file.close();
result = true;
}
#ifdef USE_WEBCAM
WcInterrupt(1);
#endif
return result;
}
bool TfsInitFile(const char *fname, uint32_t len, uint8_t init_value) {
if (!ffs_type) { return false; }
File file = ffsp->open(fname, "w");
if (!file) {
AddLog(LOG_LEVEL_INFO, PSTR("TFS: Erase failed"));
return false;
}
for (uint32_t i = 0; i < len; i++) {
file.write(&init_value, 1);
}
file.close();
return true;
}
bool TfsLoadFile(const char *fname, uint8_t *buf, uint32_t len) {
if (!ffs_type) { return false; }
File file = ffsp->open(fname, "r");
if (!file) {
AddLog(LOG_LEVEL_INFO, PSTR("TFS: File '%s' not found"), fname +1); // Skip leading slash
return false;
}
size_t flen = file.size();
if (len > flen) { len = flen; } // Adjust requested length to smaller file length
file.read(buf, len);
file.close();
return true;
}
String TfsLoadString(const char *fname) {
// Use a reasonable amount of stack space considering 4k/8k available on ESP8266/ESP32 and manageable string length
char buf[2048] = { 0 }; // Prepare empty string of max 2047 characters on stack
TfsLoadFile(fname, (uint8_t*)buf, 2047); // Leave last position as end of string ('\0')
return String(buf); // Received string or empty on error
}
bool TfsDeleteFile(const char *fname) {
if (!ffs_type) { return false; }
if (!ffsp->remove(fname)) {
AddLog(LOG_LEVEL_INFO, PSTR("TFS: Delete failed"));
return false;
}
return true;
}
bool TfsRenameFile(const char *fname1, const char *fname2) {
if (!ffs_type) { return false; }
if (!ffsp->rename(fname1, fname2)) {
AddLog(LOG_LEVEL_INFO, PSTR("TFS: Rename failed"));
return false;
}
return true;
}
/*********************************************************************************************\
* File command execute support
\*********************************************************************************************/
bool UfsExecuteCommandFileReady(void) {
return (UfsData.run_file_pos < 0); // Check file ready to disable concurrency
}
void UfsExecuteCommandFileLoop(void) {
if (UfsExecuteCommandFileReady() || !ffs_type) { return; }
if (BACKLOG_EMPTY && strlen(UfsData.run_file) && !UfsData.run_file_mutex) {
File file = ffsp->open(UfsData.run_file, "r");
if (!file || !file.seek(UfsData.run_file_pos)) {
UfsData.run_file_pos = -1; // Signal file ready
return;
}
UfsData.run_file_mutex = true;
char cmd_line[512];
cmd_line[0] = '\0'; // Clear in case of re-entry
while (file.available()) {
uint16_t index = 0;
bool comment = false;
while (file.available()) {
uint8_t buf[1];
file.read(buf, 1);
if ((buf[0] == '\n') || (buf[0] == '\r')) {
break; // End of command with linefeed or carriage return
}
// else if (index && !comment && (buf[0] == ';')) {
// break; // End of command on multi command line
// }
else if ((0 == index) && isspace(buf[0])) {
// Skip leading spaces (' ','\t','\n','\v','\f','\r')
}
else if ((0 == index) && (buf[0] == ';')) {
comment = true; // Ignore comment lines until linefeed or carriage return
}
else if (!comment && (index < sizeof(cmd_line) - 2)) {
cmd_line[index++] = buf[0]; // Build command
}
}
if ((index > 0) && (index < sizeof(cmd_line) - 1)) {
cmd_line[index] = '\0'; // Valid command received
break;
}
}
UfsData.run_file_pos = (file.available()) ? file.position() : -1;
file.close();
if (strlen(cmd_line)) {
ExecuteCommand(cmd_line, SRC_FILE);
}
UfsData.run_file_mutex = false;
}
}
bool UfsExecuteCommandFile(const char *fname) {
// Check for non-concurrency and file existance
if (UfsExecuteCommandFileReady() && TfsFileExists(fname)) {
snprintf(UfsData.run_file, sizeof(UfsData.run_file), fname);
UfsData.run_file_pos = 0; // Signal start of file
return true;
}
return false;
}
/*********************************************************************************************\
* Commands
\*********************************************************************************************/
const int UFS_FILENAME_SIZE = 48;
char* UfsFilename(char* fname, char* fname_in) {
fname_in = Trim(fname_in); // Remove possible leading spaces
snprintf_P(fname, UFS_FILENAME_SIZE, PSTR("%s%s"), ('/' == fname_in[0]) ? "" : "/", fname_in);
return fname;
}
const char kUFSCommands[] PROGMEM = "Ufs|" // Prefix
"|Type|Size|Free|Delete|Rename|Run"
#ifdef UFILESYS_STATIC_SERVING
"|Serve"
#endif
;
void (* const kUFSCommand[])(void) PROGMEM = {
&UFSInfo, &UFSType, &UFSSize, &UFSFree, &UFSDelete, &UFSRename, &UFSRun
#ifdef UFILESYS_STATIC_SERVING
,&UFSServe
#endif
};
void UFSInfo(void) {
Response_P(PSTR("{\"Ufs\":{\"Type\":%d,\"Size\":%d,\"Free\":%d}"), ufs_type, UfsInfo(0, 0), UfsInfo(1, 0));
if (ffs_type && (ffs_type != ufs_type)) {
ResponseAppend_P(PSTR(",{\"Type\":%d,\"Size\":%d,\"Free\":%d}"), ffs_type, UfsInfo(0, 1), UfsInfo(1, 1));
}
ResponseJsonEnd();
}
void UFSType(void) {
if (ffs_type && (ffs_type != ufs_type)) {
Response_P(PSTR("{\"%s\":[%d,%d]}"), XdrvMailbox.command, ufs_type, ffs_type);
} else {
ResponseCmndNumber(ufs_type);
}
}
void UFSSize(void) {
if (ffs_type && (ffs_type != ufs_type)) {
Response_P(PSTR("{\"%s\":[%d,%d]}"), XdrvMailbox.command, UfsInfo(0, 0), UfsInfo(0, 1));
} else {
ResponseCmndNumber(UfsInfo(0, 0));
}
}
void UFSFree(void) {
if (ffs_type && (ffs_type != ufs_type)) {
Response_P(PSTR("{\"%s\":[%d,%d]}"), XdrvMailbox.command, UfsInfo(1, 0), UfsInfo(1, 1));
} else {
ResponseCmndNumber(UfsInfo(1, 0));
}
}
void UFSDelete(void) {
// UfsDelete sdcard or flashfs file if only one of them available
// UfsDelete2 flashfs file if available
if (XdrvMailbox.data_len > 0) {
char fname[UFS_FILENAME_SIZE];
UfsFilename(fname, XdrvMailbox.data);
bool result = false;
if (ffs_type && (ffs_type != ufs_type) && (2 == XdrvMailbox.index)) {
result = TfsDeleteFile(fname);
} else {
result = (ufs_type && ufsp->remove(fname));
}
if (!result) {
ResponseCmndFailed();
} else {
ResponseCmndDone();
}
}
}
void UFSRename(void) {
// UfsRename sdcard or flashfs file if only one of them available
// UfsRename2 flashfs file if available
if (XdrvMailbox.data_len > 0) {
bool result = false;
char *fname1 = strtok(XdrvMailbox.data, ",");
char *fname2 = strtok(nullptr, ",");
if (fname1 && fname2) {
char fname_old[UFS_FILENAME_SIZE];
UfsFilename(fname_old, fname1);
char fname_new[UFS_FILENAME_SIZE];
UfsFilename(fname_new, fname2);
if (ffs_type && (ffs_type != ufs_type) && (2 == XdrvMailbox.index)) {
result = TfsRenameFile(fname_old, fname_new);
} else {
result = (ufs_type && ufsp->rename(fname_old, fname_new));
}
}
if (!result) {
ResponseCmndFailed();
} else {
ResponseCmndDone();
}
}
}
#ifdef UFILESYS_STATIC_SERVING
/*
* Serves a filesystem folder at a web url.
* NOTE - this is expensive on flash -> +2.5kbytes.
* like "UFSServe ,[,]"
* e.g. "UFSServe /sd/,/mysdcard/,1" - will serve the /sd/ fs folder as https:///mysdcard/ with no auth required
* e.g. "UFSServe /www/,/" - will serve the /www/ fs folder as https:/// with auth required if TAS has a password setup
* defaults to 0 - i.e. the default is to require auth if configured
* it WILL serve on / - so conflicting urls could occur. I beleive native TAS urls will have priority.
* you can serve multiple folders, and they can each be auth or noauth
*
* by default, it also enables cors on the webserver - this allows you to have
* a website external to TAS which can access the files, else the browser refuses.
*/
#include "detail/RequestHandlersImpl.h"
// class to allow us to request auth when required.
// StaticRequestHandler is in the above header
class StaticRequestHandlerAuth : public StaticRequestHandler {
public:
StaticRequestHandlerAuth(FS& fs, const char* path, const char* uri, const char* cache_header):
StaticRequestHandler(fs, path, uri, cache_header)
{
}
// we replace the handle method,
// and look for authentication only if we would serve the file.
// if we check earlier, then we will reject as unauth even though a later
// handler may be public, and so fail to serve public files.
bool handle(WebServer& server, HTTPMethod requestMethod, String requestUri) override {
if (!canHandle(requestMethod, requestUri))
return false;
log_v("StaticRequestHandler::handle: request=%s _uri=%s\r\n", requestUri.c_str(), _uri.c_str());
String path(_path);
if (!_isFile) {
// Base URI doesn't point to a file.
// If a directory is requested, look for index file.
if (requestUri.endsWith("/"))
requestUri += "index.htm";
// Append whatever follows this URI in request to get the file path.
path += requestUri.substring(_baseUriLength);
}
log_v("StaticRequestHandler::handle: path=%s, isFile=%d\r\n", path.c_str(), _isFile);
String contentType = getContentType(path);
// look for gz file, only if the original specified path is not a gz. So part only works to send gzip via content encoding when a non compressed is asked for
// if you point the the path to gzip you will serve the gzip as content type "application/x-gzip", not text or javascript etc...
if (!path.endsWith(FPSTR(mimeTable[gz].endsWith)) && !_fs.exists(path)) {
String pathWithGz = path + FPSTR(mimeTable[gz].endsWith);
if(_fs.exists(pathWithGz))
path += FPSTR(mimeTable[gz].endsWith);
}
File f = _fs.open(path, "r");
if (!f || !f.available())
return false;
if (!WebAuthenticate()) {
AddLog(LOG_LEVEL_ERROR, PSTR("UFS: serv of %s denied"), requestUri.c_str());
server.requestAuthentication();
return true;
}
if (_cache_header.length() != 0)
server.sendHeader("Cache-Control", _cache_header);
server.streamFile(f, contentType);
return true;
}
};
void UFSServe(void) {
if (XdrvMailbox.data_len > 0) {
bool result = false;
char *fpath = strtok(XdrvMailbox.data, ",");
char *url = strtok(nullptr, ",");
char *noauth = strtok(nullptr, ",");
if (fpath && url) {
char t[] = "";
StaticRequestHandlerAuth *staticHandler;
if (noauth && *noauth == '1'){
staticHandler = (StaticRequestHandlerAuth *) new StaticRequestHandler(*ffsp, fpath, url, (char *)nullptr);
} else {
staticHandler = new StaticRequestHandlerAuth(*ffsp, fpath, url, (char *)nullptr);
}
if (staticHandler) {
//Webserver->serveStatic(url, *ffsp, fpath);
Webserver->addHandler(staticHandler);
Webserver->enableCORS(true);
result = true;
} else {
// could this happen? only lack of memory.
result = false;
}
}
if (!result) {
ResponseCmndFailed();
} else {
ResponseCmndDone();
}
}
}
#endif // UFILESYS_STATIC_SERVING
void UFSRun(void) {
if (XdrvMailbox.data_len > 0) {
char fname[UFS_FILENAME_SIZE];
if (UfsExecuteCommandFile(UfsFilename(fname, XdrvMailbox.data))) {
ResponseClear();
} else {
ResponseCmndFailed();
}
} else {
bool not_active = UfsExecuteCommandFileReady();
UfsData.run_file_pos = -1;
ResponseCmndChar(not_active ? PSTR(D_JSON_DONE) : PSTR(D_JSON_ABORTED));
}
}
/*********************************************************************************************\
* Web support
\*********************************************************************************************/
#ifdef USE_WEBSERVER
const char UFS_WEB_DIR[] PROGMEM =
"