feat: upload offline maps to SD

This commit is contained in:
Michael A. Cojocari committed 2026-09-16 20:53:54 -04:00
1 parent 08ad95c5c2
commit e83bb7582e
7 files changed
+483 -108

No files matched your search

+42
View File
@@ -46,6 +46,48 @@ Lua apps use the on-device SDK described in [LUA_APPS.md](LUA_APPS.md). For
WAV/MP3 playback, including a ready-to-sideload transport test app and exact SD
and internal-storage paths, see [AUDIO_PLAYBACK_TESTING.md](AUDIO_PLAYBACK_TESTING.md).
## Offline map tiles
Builds with a microSD card expose a **Transfer** app for authenticated browser
uploads over the local Wi-Fi network. WadaMesh accepts unpacked 256x256 PNG XYZ
tiles in a `z/x/y.png` directory tree.
To create a compatible pack with [QGIS](https://qgis.org/):
1. Add a map source whose licence and service terms explicitly permit offline
or bulk use. A self-hosted source is also suitable. Do **not** bulk-download
from `tile.openstreetmap.org`: the official
[OSM tile policy](https://operations.osmfoundation.org/policies/tiles/)
prohibits offline downloads from that community service.
2. Frame only the area needed in the QGIS map canvas. Tile counts quadruple at
every added zoom level, so a small region and a practical range such as
zoom 8 through 16 are preferable to exporting an entire country at zoom 19.
3. Open **Processing Toolbox → Raster tools → Generate XYZ tiles (Directory)**.
4. Use the map-canvas extent, choose minimum and maximum zooms within 3–19,
select **PNG**, set tile width and height to **256**, leave **Use inverted
tile Y axis (TMS conventions)** disabled, and select an output directory.
5. Confirm the output contains paths such as `12/2048/1362.png`. If obtaining
an existing pack instead, extract it first and confirm it has the same XYZ
directory layout and that its provider permits offline use and redistribution.
To upload the pack:
1. Connect the device and the browser to the same Wi-Fi network.
2. Open **Transfer** from the device's app drawer, visit the displayed URL, and
enter the six-digit session code.
3. Choose **Offline OSM map (/tiles)**, select the generated output folder, and
start the upload. The browser ignores unrelated files and uploads valid tiles
sequentially while preserving their coordinates.
4. Existing tiles are skipped by default, allowing an interrupted upload to be
resumed. Enable **Replace existing map tiles** when intentionally refreshing
a pack.
5. Close Transfer after completion. In **Map options**, disable **Topographic
map** and enable **Tiles from SD card**. Disable Wi-Fi to verify that the
uploaded area renders offline.
Individual PNG tiles must be no larger than 256 KB. Keep the map provider's
required attribution and licence information with any pack you distribute.
## Build
[PlatformIO](https://platformio.org/) pulls the core fork and all libraries
+13
View File
@@ -70,6 +70,19 @@ you leave the map or anything opens over it, so a stale pan can never eat a
key press later. Auto-follow pauses while you pan and resumes when you exit.
Entering the map fresh always starts in normal navigation.
## Files and offline maps
From the Home tab, press **HOME** to open the app drawer. **Files** browses and
organizes the M9's SD card. **Transfer** starts an authenticated browser session
for uploads and downloads over the same Wi-Fi network.
To install an offline map, open Transfer, visit the displayed URL, enter the
six-digit code, choose **Offline OSM map (/tiles)**, and select an unpacked
folder containing `z/x/y.png` tiles. After it completes, open Map options,
select OSM, and enable **Tiles from SD card**. See the
[offline map tile guide](README.md#offline-map-tiles) for the QGIS export steps
and map-provider policy requirements.
## Typing & editing
Inside a text field the left/right arrows move the caret — and when the caret
+1 -1
View File
@@ -4,7 +4,7 @@
// card backend. Keep internal SPIFFS/LittleFS-only boards out: /transfer can hold
// large uploads and is intended to remain user-removable storage.
#if defined(MULTI_TRANSPORT_COMPANION) && \
(defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || \
(defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || \
defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) || \
defined(HAS_WIO_TRACKER_L2) || defined(HAS_TDISPLAY_P4) || \
defined(HAS_TANMATSU))
+19 -9
View File
@@ -22,6 +22,7 @@ R"FILEPAGE(<!doctype html>
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:linear-gradient(150deg,#0b0d0e,#111817);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
main{width:min(560px,100%);margin:0 auto;padding:24px 16px 40px}header{display:flex;align-items:center;gap:11px;margin-bottom:24px}header b{font-size:20px;letter-spacing:0}header i{display:block;width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent)}
section{border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:16px;margin:12px 0}label{display:block;color:var(--muted);font-size:12px;margin-bottom:7px}input,button,select{font:inherit;border-radius:6px;min-height:42px}input[type=password]{width:100%;padding:9px 11px;background:#0d1112;color:var(--text);border:1px solid var(--line);font-size:18px;letter-spacing:3px}button,select{border:1px solid var(--line);background:#202627;color:var(--text);padding:8px 14px}button{cursor:pointer}button.primary{background:var(--accent);border-color:var(--accent);color:#061412;font-weight:700}button.danger{color:var(--danger)}button:disabled,select:disabled{opacity:.4;cursor:not-allowed}.row{display:flex;gap:8px;margin-top:10px}.row>*{flex:1}.file-tools{align-items:center}.file-tools b{flex:1.4}.file-tools select{min-width:0}.drop{display:block;border:1px dashed #53605e;border-radius:8px;padding:24px 12px;text-align:center;color:var(--muted);cursor:pointer}.drop.ready{border-color:var(--accent);color:var(--text)}#pick{position:absolute;opacity:0;pointer-events:none}#name{margin-top:10px;overflow-wrap:anywhere}progress{width:100%;height:10px;margin-top:14px;accent-color:var(--accent)}#status{min-height:20px;margin-top:10px;color:var(--muted);font-size:13px}#status.ok{color:var(--accent)}#status.err{color:var(--danger)}small{display:block;color:var(--muted);line-height:1.5;margin-top:12px}.file{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--line)}.file:last-child{border-bottom:0}.file span{flex:1;min-width:0;overflow-wrap:anywhere}.file small{margin:2px 0 0}.actions{display:flex;gap:6px}.actions button{padding:7px 10px}.empty{color:var(--muted);padding:10px 0}
.check{display:none;align-items:center;gap:8px;margin:12px 0;color:var(--text)}.check input{width:18px;height:18px;min-height:0;margin:0}
</style>
</head>
<body><main>
@@ -32,13 +33,16 @@ section{border:1px solid var(--line);background:var(--panel);border-radius:8px;p
<button id="connect" class="primary" style="width:100%;margin-top:10px">Connect</button>
</section>
<section>
<label for="destination">Upload destination</label>
<select id="destination" style="width:100%"><option value="transfer">Files (/transfer)</option><option value="maps">Offline OSM map (/tiles)</option></select>
<label id="replace-row" class="check"><input id="replace" type="checkbox">Replace existing map tiles</label>
<label id="drop" class="drop" for="pick">Choose or drop a file</label>
<input id="pick" type="file">
<div id="name">No file selected</div>
<progress id="progress" max="100" value="0"></progress>
<div class="row"><button id="upload" class="primary" disabled>Upload</button><button id="cancel" class="danger" disabled>Cancel</button></div>
<div id="status">Enter the session code to connect.</div>
<small>Files are uploaded to the device's SD card in <b>/transfer</b>. The transfer session ends when you leave the File Transfer app on the device.</small>
<small id="upload-help">Files are uploaded to the device's SD card in /transfer. The transfer session ends when you leave the File Transfer app on the device.</small>
</section>
<section>
<div class="row file-tools" style="margin-top:0"><b>Device files</b><select id="filter" aria-label="File type" disabled><option value="all">ALL</option><option value="screenshots">Screenshots</option><option value="crashes">Crash reports</option><option value="backups">Backups</option><option value="transfers">Transfers</option></select><button id="refresh" disabled>Refresh</button></div>
@@ -47,20 +51,25 @@ section{border:1px solid var(--line);background:var(--panel);border-radius:8px;p
</section>
</main>
<script>
const E=id=>document.getElementById(id),dec=new TextDecoder(),CHUNK=2048,MAX=512*1024*1024,MAX_DOWNLOAD=64*1024*1024;
const E=id=>document.getElementById(id),dec=new TextDecoder(),CHUNK=2048,MAX=512*1024*1024,MAX_TILE=256*1024,MAX_DOWNLOAD=64*1024*1024;
let ws=null,file=null,name='',offset=0,crc=0xffffffff,state='idle',pendingEnd=false,expectedAck=0,authed=false,cancelTimer=0,cancelText='',cancelKind='';
let selection=[],uploadIndex=0,uploadTotal=0,uploadProcessed=0,uploadSkipped=0,replaceTiles=false;
let entries=[],downloadParts=[],downloadSize=0,downloadOffset=0,downloadName='';
const table=new Uint32Array(256);for(let n=0;n<256;n++){let c=n;for(let k=0;k<8;k++)c=(c&1)?(0xedb88320^(c>>>1)):(c>>>1);table[n]=c>>>0}
function crcAdd(c,a){for(let i=0;i<a.length;i++)c=table[(c^a[i])&255]^(c>>>8);return c>>>0}
function status(text,kind=''){E('status').textContent=text;E('status').className=kind}
function controls(){const ready=ws&&ws.readyState===1&&authed&&state==='ready';E('upload').disabled=!(ready&&file);E('refresh').disabled=!ready;E('filter').disabled=!ready;E('cancel').disabled=state!=='uploading'&&state!=='downloading'}
function resetUpload(){if(cancelTimer){clearTimeout(cancelTimer);cancelTimer=0}offset=0;crc=0xffffffff;pendingEnd=false;expectedAck=0;cancelText=cancelKind='';downloadParts=[];downloadSize=downloadOffset=0;E('progress').value=0;state=authed?'ready':'idle';controls()}
function controls(){const ready=ws&&ws.readyState===1&&authed&&state==='ready',active=['starting','uploading','cancelling','downloading','listing'].includes(state);E('upload').disabled=!(ready&&selection.length);E('refresh').disabled=!ready;E('filter').disabled=!ready;E('destination').disabled=active;E('replace').disabled=active;E('cancel').disabled=state!=='starting'&&state!=='uploading'&&state!=='downloading'}
function resetUpload(){if(cancelTimer){clearTimeout(cancelTimer);cancelTimer=0}offset=0;crc=0xffffffff;pendingEnd=false;expectedAck=0;uploadIndex=uploadProcessed=uploadSkipped=0;cancelText=cancelKind='';downloadParts=[];downloadSize=downloadOffset=0;E('progress').value=0;state=authed?'ready':'idle';controls()}
function safeName(raw){return raw.replace(/[^A-Za-z0-9._-]/g,'_').replace(/^\.+/,'').slice(0,64)}
function choose(f){if(!f)return;if(f.size<=0||f.size>MAX){status('File must be between 1 byte and 512 MB.','err');return}file=f;name=safeName(f.name)||'upload.bin';E('name').textContent=(name===f.name?name:(f.name+' → '+name))+' ('+f.size.toLocaleString()+' bytes)';E('drop').classList.add('ready');controls()}
function mapMode(){return E('destination').value==='maps'}
function mapEntry(f){const parts=(f.webkitRelativePath||f.name).split('/').filter(Boolean);if(parts.length<3||f.size<=0||f.size>MAX_TILE)return null;const zt=parts[parts.length-3],xt=parts[parts.length-2],m=parts[parts.length-1].match(/^(\d+)\.png$/i);if(!/^\d+$/.test(zt)||!/^\d+$/.test(xt)||!m)return null;const z=Number(zt),x=Number(xt),y=Number(m[1]),span=2**z;if(!Number.isSafeInteger(z)||!Number.isSafeInteger(x)||!Number.isSafeInteger(y)||z<3||z>19||x<0||y<0||x>=span||y>=span)return null;return{file:f,name:z+'/'+x+'/'+y+'.png',map:true}}
function choose(input){const files=Array.from(input||[]);selection=[];file=null;name='';if(mapMode()){const seen=new Set();let rejected=0;for(const f of files){const item=mapEntry(f);if(!item||seen.has(item.name)){rejected++;continue}seen.add(item.name);selection.push(item)}if(!selection.length){E('name').textContent='No valid z/x/y.png tiles selected';E('drop').classList.remove('ready');status('Choose a folder containing OSM z/x/y.png tiles (256 KB maximum each).','err');controls();return}const bytes=selection.reduce((sum,item)=>sum+item.file.size,0);E('name').textContent=selection.length.toLocaleString()+' tiles ('+bytes.toLocaleString()+' bytes)'+(rejected?' · '+rejected+' skipped':'');}else{const f=files[0];if(!f)return;if(f.size<=0||f.size>MAX){status('File must be between 1 byte and 512 MB.','err');return}name=safeName(f.name)||'upload.bin';selection=[{file:f,name,map:false}];E('name').textContent=(name===f.name?name:(f.name+' → '+name))+' ('+f.size.toLocaleString()+' bytes)'}file=selection[0].file;E('drop').classList.add('ready');status(authed?'Ready to upload.':'Enter the session code to connect.');controls()}
function destinationChanged(){const maps=mapMode();selection=[];file=null;name='';E('pick').value='';E('pick').multiple=maps;if(maps)E('pick').setAttribute('webkitdirectory','');else E('pick').removeAttribute('webkitdirectory');E('replace-row').style.display=maps?'flex':'none';E('drop').textContent=maps?'Choose an OSM tile folder':'Choose or drop a file';E('name').textContent='No file selected';E('drop').classList.remove('ready');E('upload-help').textContent=maps?'Select a folder containing z/x/y.png tiles. They are stored on the SD card under /tiles; enable Tiles from SD card in Map options where available.':"Files are uploaded to the device's SD card in /transfer. The transfer session ends when you leave the File Transfer app on the device.";controls()}
function connect(){const code=E('code').value.trim();if(!/^\d{6}$/.test(code)){status('Enter the six-digit code.','err');return}if(ws)ws.close();authed=false;state='connecting';controls();const socket=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/files');ws=socket;socket.binaryType='arraybuffer';socket.onopen=()=>{if(ws===socket){status('Authenticating…');socket.send('AUTH '+code)}};socket.onerror=()=>{if(ws===socket)status('Connection error.','err')};socket.onclose=()=>{if(ws!==socket)return;ws=null;authed=false;resetUpload();status('Disconnected. Reopen File Transfer on the device if the session ended.','err')};socket.onmessage=e=>{if(ws===socket)onMessage(e)}}
function onMessage(ev){const bytes=typeof ev.data==='string'?null:new Uint8Array(ev.data);if(bytes&&bytes[0]===1){if(state!=='cancelling')downloadChunk(bytes);return}const text=typeof ev.data==='string'?ev.data:dec.decode(bytes);if(state==='cancelling'&&text!=='CANCELLED')return;if(text==='AUTH OK'){authed=true;state='ready';status('Connected.','ok');controls();refreshList();return}if(text==='READY'){state='uploading';status('Uploading…');controls();sendNext();return}if(text.startsWith('ACK ')){const next=Number(text.slice(4));if(state!=='uploading'||!Number.isSafeInteger(next)||next!==expectedAck||next>file.size){fail('Invalid acknowledgement.');return}offset=next;expectedAck=0;E('progress').value=Math.floor(offset*100/file.size);sendNext();return}if(text.startsWith('DONE ')){const completed=Number(text.slice(5));if(state!=='uploading'||!pendingEnd||completed!==file.size||offset!==file.size){fail('Invalid completion response.');return}E('progress').value=100;state='ready';controls();status('Upload complete: '+name,'ok');refreshList();return}if(text.startsWith('ENTRY ')){const m=text.match(/^ENTRY (\d+) (.+)$/);if(!m){fail('Invalid file list entry.');return}entries.push({size:Number(m[1]),path:m[2]});ws.send('LIST NEXT');return}if(text==='LIST DONE'){state='ready';renderFiles();controls();status(entries.length?'File list updated.':'No downloadable device files yet.','ok');return}if(text.startsWith('FILE ')){const m=text.match(/^FILE (\d+) (.+)$/);if(!m){fail('Invalid download metadata.');return}downloadSize=Number(m[1]);if(!Number.isSafeInteger(downloadSize)||downloadSize<=0||downloadSize>MAX_DOWNLOAD){fail('Downloads are limited to 64 MB in this browser tool.');return}downloadName=m[2];downloadOffset=0;downloadParts=[];state='downloading';E('progress').value=0;controls();status('Downloading '+downloadName+'…');ws.send('READ 0');return}if(text==='CANCELLED'){const message=cancelText||'Transfer cancelled.',kind=cancelKind;resetUpload();status(message,kind);return}if(text.startsWith('ERR ')){fail(text.slice(4));return}}
function onMessage(ev){const bytes=typeof ev.data==='string'?null:new Uint8Array(ev.data);if(bytes&&bytes[0]===1){if(state!=='cancelling')downloadChunk(bytes);return}const text=typeof ev.data==='string'?ev.data:dec.decode(bytes);if(state==='cancelling'&&text!=='CANCELLED')return;if(text==='AUTH OK'){authed=true;state='ready';status('Connected.','ok');controls();refreshList();return}if(text==='READY'){state='uploading';status('Uploading '+(uploadIndex+1)+' / '+selection.length+': '+name+'…');controls();sendNext();return}if(text==='SKIP'){if(state!=='starting'||!selection[uploadIndex]||!selection[uploadIndex].map){fail('Unexpected skip response.');return}uploadSkipped++;uploadProcessed+=file.size;uploadIndex++;startNextUpload();return}if(text.startsWith('ACK ')){const next=Number(text.slice(4));if(state!=='uploading'||!Number.isSafeInteger(next)||next!==expectedAck||next>file.size){fail('Invalid acknowledgement.');return}offset=next;expectedAck=0;E('progress').value=Math.floor((uploadProcessed+offset)*100/uploadTotal);sendNext();return}if(text.startsWith('DONE ')){const completed=Number(text.slice(5));if(state!=='uploading'||!pendingEnd||completed!==file.size||offset!==file.size){fail('Invalid completion response.');return}uploadProcessed+=completed;uploadIndex++;startNextUpload();return}if(text.startsWith('ENTRY ')){const m=text.match(/^ENTRY (\d+) (.+)$/);if(!m){fail('Invalid file list entry.');return}entries.push({size:Number(m[1]),path:m[2]});ws.send('LIST NEXT');return}if(text==='LIST DONE'){state='ready';renderFiles();controls();status(entries.length?'File list updated.':'No downloadable device files yet.','ok');return}if(text.startsWith('FILE ')){const m=text.match(/^FILE (\d+) (.+)$/);if(!m){fail('Invalid download metadata.');return}downloadSize=Number(m[1]);if(!Number.isSafeInteger(downloadSize)||downloadSize<=0||downloadSize>MAX_DOWNLOAD){fail('Downloads are limited to 64 MB in this browser tool.');return}downloadName=m[2];downloadOffset=0;downloadParts=[];state='downloading';E('progress').value=0;controls();status('Downloading '+downloadName+'…');ws.send('READ 0');return}if(text==='CANCELLED'){const message=cancelText||'Transfer cancelled.',kind=cancelKind;resetUpload();status(message,kind);return}if(text.startsWith('ERR ')){fail(text.slice(4));return}fail('Unexpected device response.')}
async function sendNext(){if(state!=='uploading'||!file)return;if(offset>=file.size){if(!pendingEnd){pendingEnd=true;ws.send('END '+((crc^0xffffffff)>>>0).toString(16).padStart(8,'0'))}return}const start=offset;const bytes=new Uint8Array(await file.slice(start,Math.min(start+CHUNK,file.size)).arrayBuffer());if(state!=='uploading'||start!==offset)return;crc=crcAdd(crc,bytes);const frame=new Uint8Array(bytes.length+4);new DataView(frame.buffer).setUint32(0,start,true);frame.set(bytes,4);expectedAck=start+bytes.length;ws.send(frame)}
function upload(){if(!file||state!=='ready')return;offset=0;crc=0xffffffff;pendingEnd=false;expectedAck=0;E('progress').value=0;state='starting';controls();status('Preparing '+name+'…');ws.send('BEGIN '+file.size+' '+name)}
function startNextUpload(){if(uploadIndex>=selection.length){E('progress').value=100;state='ready';controls();if(mapMode())status('Map upload complete: '+selection.length+' tiles'+(uploadSkipped?' ('+uploadSkipped+' existing skipped)':'')+'. Enable Tiles from SD card in Map options.','ok');else{status('Upload complete: '+name,'ok');refreshList()}return}const item=selection[uploadIndex];file=item.file;name=item.name;offset=0;crc=0xffffffff;pendingEnd=false;expectedAck=0;state='starting';controls();status('Preparing '+(uploadIndex+1)+' / '+selection.length+': '+name+'…');ws.send(item.map?('MAPBEGIN '+file.size+' '+(replaceTiles?'R':'S')+' '+name):('BEGIN '+file.size+' '+name))}
function upload(){if(!selection.length||state!=='ready')return;uploadIndex=0;uploadProcessed=0;uploadSkipped=0;uploadTotal=selection.reduce((sum,item)=>sum+item.file.size,0);replaceTiles=E('replace').checked;E('progress').value=0;startNextUpload()}
function requestCancel(text='Transfer cancelled.',kind=''){if(state==='cancelling')return;if(ws&&ws.readyState===1&&authed&&state!=='ready'&&state!=='idle'&&state!=='connecting'){cancelText=text;cancelKind=kind;state='cancelling';controls();status('Cancelling…');ws.send('CANCEL');cancelTimer=setTimeout(()=>{if(state!=='cancelling')return;const socket=ws;resetUpload();status(text,kind);if(socket&&socket.readyState<2)socket.close()},5000);return}resetUpload();status(text,kind)}
function cancel(){requestCancel()}
function fail(text){if(state==='starting'||state==='uploading'||state==='downloading'||state==='listing'){requestCancel(text,'err');return}resetUpload();status(text,'err')}
@@ -70,8 +79,9 @@ function renderFiles(){const box=E('files'),filter=E('filter').value,shown=entri
function startDownload(path){if(!authed||state!=='ready')return;state='starting';controls();status('Opening '+path+'…');ws.send('GET '+path)}
function deleteDeviceFile(path){if(!authed||state!=='ready'||!confirm('Delete '+path+' from the device?'))return;entries=[];state='listing';controls();status('Deleting '+path+'…');ws.send('DELETE '+path)}
function downloadChunk(bytes){if(state!=='downloading'||bytes.length<6){fail('Unexpected download data.');return}const at=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength).getUint32(1,true),part=bytes.slice(5);if(at!==downloadOffset||downloadOffset+part.length>downloadSize){fail('Invalid download offset.');return}downloadParts.push(part);downloadOffset+=part.length;E('progress').value=Math.floor(downloadOffset*100/downloadSize);if(downloadOffset<downloadSize){ws.send('READ '+downloadOffset);return}if(downloadOffset!==downloadSize){fail('Download size mismatch.');return}const href=URL.createObjectURL(new Blob(downloadParts)),a=document.createElement('a');a.href=href;a.download=downloadName;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(href),1000);state='ready';downloadParts=[];controls();status('Downloaded '+downloadName,'ok')}
E('connect').onclick=connect;E('upload').onclick=upload;E('cancel').onclick=cancel;E('refresh').onclick=refreshList;E('filter').onchange=renderFiles;E('pick').onchange=e=>choose(e.target.files[0]);
const drop=E('drop');drop.ondragover=e=>{e.preventDefault();drop.classList.add('ready')};drop.ondragleave=()=>{if(!file)drop.classList.remove('ready')};drop.ondrop=e=>{e.preventDefault();choose(e.dataTransfer.files[0])};
E('connect').onclick=connect;E('upload').onclick=upload;E('cancel').onclick=cancel;E('refresh').onclick=refreshList;E('filter').onchange=renderFiles;E('destination').onchange=destinationChanged;E('pick').onchange=e=>choose(e.target.files);
const drop=E('drop');drop.ondragover=e=>{e.preventDefault();drop.classList.add('ready')};drop.ondragleave=()=>{if(!file)drop.classList.remove('ready')};drop.ondrop=e=>{e.preventDefault();choose(e.dataTransfer.files)};
destinationChanged();
</script></body></html>)FILEPAGE";
static const char WS_HTTP_FILES_DISABLED[] =
@@ -51,6 +51,55 @@ inline bool fileNameValid(const char* name) {
return true;
}
struct MapTilePath {
uint8_t zoom;
uint32_t x;
uint32_t y;
};
inline bool parseDecimalSegment(const char* begin, const char* end, uint32_t* value) {
if (!begin || !end || !value || begin >= end) return false;
uint32_t out = 0;
for (const char* cursor = begin; cursor < end; ++cursor) {
if (*cursor < '0' || *cursor > '9') return false;
const uint32_t digit = static_cast<uint32_t>(*cursor - '0');
if (out > (UINT32_MAX - digit) / 10u) return false;
out = out * 10u + digit;
}
*value = out;
return true;
}
inline bool mapTilePathValid(const char* path, MapTilePath* parsed = nullptr) {
if (!path || !path[0] || strlen(path) > 48) return false;
const char* first_slash = strchr(path, '/');
if (!first_slash) return false;
const char* second_slash = strchr(first_slash + 1, '/');
if (!second_slash || strchr(second_slash + 1, '/')) return false;
const char* extension = strrchr(second_slash + 1, '.');
if (!extension || (strcmp(extension, ".png") != 0 && strcmp(extension, ".PNG") != 0))
return false;
uint32_t zoom = 0, x = 0, y = 0;
if (!parseDecimalSegment(path, first_slash, &zoom) ||
!parseDecimalSegment(first_slash + 1, second_slash, &x) ||
!parseDecimalSegment(second_slash + 1, extension, &y) ||
zoom < 3 || zoom > 19) return false;
const uint32_t span = 1u << zoom;
if (x >= span || y >= span) return false;
if (parsed) {
parsed->zoom = static_cast<uint8_t>(zoom);
parsed->x = x;
parsed->y = y;
}
return true;
}
inline bool pngSignatureValid(const uint8_t* data, size_t len) {
static const uint8_t signature[] = { 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n' };
return data && len >= sizeof signature && memcmp(data, signature, sizeof signature) == 0;
}
inline bool firmwareExportName(const char* name) {
if (!fileNameValid(name)) return false;
if (strcmp(name, "wadamesh-crash.elf") == 0 ||
+336 -98
View File
@@ -5832,6 +5832,9 @@ static void renderMapTiles();
static void renderMapMarkers();
static void freeMapTiles();
static void mapNoteStorageChanged(); // tile storage appeared/vanished: adopt, invalidate, re-render
#if CAP_MICROSD && !CAP_SD
static volatile bool s_map_sd_storage_changed = false; // defer SD_MMC backend swaps out of active file I/O
#endif
// Single entry point used by tabChangedCb. Recenters on self GPS and
// rebuilds the tile grid. Defined alongside the map state below.
static void onMapTabActivated();
@@ -23925,8 +23928,31 @@ static void fmRefresh() {
static bool s_tan_sd_mounted = false;
static uint64_t s_tan_sd_size = 0;
static uint32_t s_tan_sd_retry_after = 0; // backoff so an absent/cold card isn't re-probed every render
static uint32_t s_tan_sd_probe_ok_ms = 0;
static bool tanSdProbeAlive(bool force = false) {
if (!s_tan_sd_mounted || SD_MMC.cardType() == CARD_NONE) return false;
const uint32_t now = millis();
if (!force && s_tan_sd_probe_ok_ms && now - s_tan_sd_probe_ok_ms < 1000) return true;
File root = SD_MMC.open("/", FILE_READ);
const bool alive = root && root.isDirectory();
if (root) root.close();
if (alive) {
s_tan_sd_probe_ok_ms = now ? now : 1;
return true;
}
s_tan_sd_mounted = false;
s_tan_sd_probe_ok_ms = 0;
s_tan_sd_retry_after = now + 8000;
sdMountDiagSetMounted(false, 0);
#if CAP_MICROSD && !CAP_SD
s_map_sd_storage_changed = true;
#endif
return false;
}
static bool tanSdTryMount() {
if (s_tan_sd_mounted) return true;
if (s_tan_sd_mounted) return tanSdProbeAlive();
#if CAP_LUA_AUDIO
if (luaAudioStorageBusy()) return false;
#endif
@@ -23935,8 +23961,11 @@ static bool tanSdTryMount() {
if (SD_MMC.cardType() != CARD_NONE) {
s_tan_sd_mounted = true;
s_tan_sd_size = SD_MMC.cardSize();
sdMountDiagSetMounted(true, 0); // preserve the boot-recorded clock when adopting its mount
return true;
if (tanSdProbeAlive(true)) {
sdMountDiagSetMounted(true, 0); // preserve the boot-recorded clock when adopting its mount
return true;
}
return false;
}
sdMountDiagBegin();
bool attempted = false;
@@ -23963,8 +23992,11 @@ static bool tanSdTryMount() {
if (card_ready) {
s_tan_sd_mounted = true;
s_tan_sd_size = SD_MMC.cardSize();
sdMountDiagSetMounted(true, mount_hz);
return true;
if (tanSdProbeAlive(true)) {
sdMountDiagSetMounted(true, mount_hz);
return true;
}
return false;
}
#if defined(HAS_WIO_TRACKER_L2)
// On the L2 the card is not just a browse target: when boot adopted it,
@@ -23989,7 +24021,12 @@ static bool tanSdTryMount() {
static void tanSdClickCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
s_tan_sd_retry_after = 0; // user tapped explicitly — bypass the backoff
if (tanSdTryMount()) fmOpenStorage(&SD_MMC, "SD", "/");
if (tanSdTryMount()) {
#if CAP_MICROSD && !CAP_SD
s_map_sd_storage_changed = true;
#endif
fmOpenStorage(&SD_MMC, "SD", "/");
}
else if (g_lv.task) g_lv.task->showAlert(TR("No SD card (or unreadable format)"), 2000);
}
#endif
@@ -25959,8 +25996,12 @@ static uint32_t s_file_transfer_download_offset = 0;
static char s_file_transfer_name[65] = {0};
static char s_file_transfer_final[96] = {0};
static char s_file_transfer_result[112] = {0};
static bool s_file_transfer_map_upload = false;
static bool s_file_transfer_replace_existing = false;
static bool s_file_transfer_map_dirty = false;
static constexpr const char* kFileTransferTemp = "/transfer/.upload.part";
static constexpr uint32_t kFileTransferMaxBytes = 512u * 1024u * 1024u;
static constexpr uint32_t kMapTileMaxBytes = 256u * 1024u;
static constexpr uint32_t kFileTransferIdleMs = 10u * 60u * 1000u;
static constexpr uint32_t kFileTransferChunkTimeoutMs = 30000u;
static void closeFileTransferPage();
@@ -25975,7 +26016,7 @@ static fs::FS& fileTransferStorage() {
static bool fileTransferStorageMounted() {
#if WADA_WEB_FILE_TRANSFER_SDMMC
return SD_MMC.cardType() != CARD_NONE;
return s_tan_sd_mounted && SD_MMC.cardType() != CARD_NONE;
#else
return s_sd_mounted && SD.cardType() != CARD_NONE;
#endif
@@ -25996,6 +26037,9 @@ static void fileTransferStorageIoFailed() {
s_tan_sd_mounted = false;
s_tan_sd_retry_after = millis() + 8000;
sdMountDiagSetMounted(false, 0);
#if CAP_MICROSD && !CAP_SD
s_map_sd_storage_changed = true;
#endif
#endif
}
@@ -26015,6 +26059,8 @@ static void fileTransferResetUpload(bool remove_temp) {
s_file_transfer_crc = 0xFFFFFFFFu;
s_file_transfer_name[0] = '\0';
s_file_transfer_final[0] = '\0';
s_file_transfer_map_upload = false;
s_file_transfer_replace_existing = false;
}
static void fileTransferResetRead(bool clear_queued_data = true) {
@@ -26044,6 +26090,75 @@ static void fileTransferFail(const char* text) {
fileTransferReply(reply);
}
static bool fileTransferPrepareUpload() {
if (!fileTransferStorageReady()) {
fileTransferFail("SD card unavailable");
return false;
}
fs::FS& storage = fileTransferStorage();
fileTransferResetRead();
fileTransferResetUpload(true);
if (storage.exists(kFileTransferTemp)) {
fileTransferFail("cannot clear temporary file");
return false;
}
if (!storage.exists("/transfer") && !storage.mkdir("/transfer")) {
fileTransferStorageIoFailed();
fileTransferFail("cannot create transfer directory");
return false;
}
return true;
}
static bool fileTransferEnsureDirectory(fs::FS& storage, const char* path) {
if (storage.exists(path)) {
File entry = storage.open(path, FILE_READ);
const bool directory = entry && entry.isDirectory();
if (entry) entry.close();
return directory;
}
return storage.mkdir(path);
}
enum class FileTransferExistingMode : uint8_t { Reject, Skip, Replace };
static void fileTransferStartUpload(uint32_t declared, const char* name,
const char* final_path,
FileTransferExistingMode existing_mode,
bool map_upload) {
fs::FS& storage = fileTransferStorage();
snprintf(s_file_transfer_final, sizeof s_file_transfer_final, "%s", final_path);
if (storage.exists(s_file_transfer_final)) {
if (existing_mode == FileTransferExistingMode::Skip) {
snprintf(s_file_transfer_result, sizeof s_file_transfer_result,
"Skipped existing %s", name);
fileTransferReply("SKIP");
return;
}
if (existing_mode == FileTransferExistingMode::Reject) {
fileTransferFail("file already exists");
return;
}
}
s_file_transfer_file = storage.open(kFileTransferTemp, FILE_WRITE);
markSdIo();
if (!s_file_transfer_file) {
fileTransferStorageIoFailed();
fileTransferFail("cannot create temporary file");
return;
}
snprintf(s_file_transfer_name, sizeof s_file_transfer_name, "%s", name);
s_file_transfer_expected = declared;
s_file_transfer_received = 0;
s_file_transfer_crc = 0xFFFFFFFFu;
s_file_transfer_result[0] = '\0';
s_file_transfer_map_upload = map_upload;
s_file_transfer_replace_existing = existing_mode == FileTransferExistingMode::Replace;
s_file_transfer_uploading = true;
fileTransferReply("READY");
}
static void fileTransferBegin(const char* command) {
unsigned long declared = 0;
char name[65] = {0};
@@ -26054,42 +26169,60 @@ static void fileTransferBegin(const char* command) {
fileTransferFail("invalid file name or size");
return;
}
if (!fileTransferStorageReady()) {
fileTransferFail("SD card unavailable");
if (!fileTransferPrepareUpload()) return;
char final_path[96];
snprintf(final_path, sizeof final_path, "/transfer/%s", name);
fileTransferStartUpload(static_cast<uint32_t>(declared), name, final_path,
FileTransferExistingMode::Reject, false);
}
static void fileTransferBeginMap(const char* command) {
unsigned long declared = 0;
char mode = 0;
char relative[49] = {0};
char extra = 0;
WebFileTransferProtocol::MapTilePath tile = {};
if (!command || sscanf(command, "MAPBEGIN %lu %c %48s %c",
&declared, &mode, relative, &extra) != 3 ||
declared == 0 || declared > kMapTileMaxBytes ||
(mode != 'S' && mode != 'R') ||
!WebFileTransferProtocol::mapTilePathValid(relative, &tile)) {
fileTransferFail("invalid map tile path or size");
return;
}
if (!fileTransferPrepareUpload()) return;
fs::FS& storage = fileTransferStorage();
fileTransferResetRead();
fileTransferResetUpload(true);
if (storage.exists(kFileTransferTemp)) {
fileTransferFail("cannot clear temporary file");
return;
}
if (!storage.exists("/transfer") && !storage.mkdir("/transfer")) {
char zoom_dir[32], x_dir[48], final_path[80], canonical[49];
snprintf(zoom_dir, sizeof zoom_dir, "/tiles/%u", static_cast<unsigned>(tile.zoom));
snprintf(x_dir, sizeof x_dir, "%s/%lu", zoom_dir, static_cast<unsigned long>(tile.x));
if (!fileTransferEnsureDirectory(storage, "/tiles") ||
!fileTransferEnsureDirectory(storage, zoom_dir) ||
!fileTransferEnsureDirectory(storage, x_dir)) {
fileTransferStorageIoFailed();
fileTransferFail("cannot create transfer directory");
fileTransferFail("cannot create map tile directories");
return;
}
snprintf(s_file_transfer_final, sizeof s_file_transfer_final, "/transfer/%s", name);
if (storage.exists(s_file_transfer_final)) {
fileTransferFail("file already exists");
return;
snprintf(canonical, sizeof canonical, "%u/%lu/%lu.png",
static_cast<unsigned>(tile.zoom), static_cast<unsigned long>(tile.x),
static_cast<unsigned long>(tile.y));
snprintf(final_path, sizeof final_path, "/tiles/%s", canonical);
char backup_path[sizeof s_file_transfer_final];
snprintf(backup_path, sizeof backup_path, "%s.bak", final_path);
if (storage.exists(backup_path)) {
const bool recovered = storage.exists(final_path)
? storage.remove(backup_path)
: storage.rename(backup_path, final_path);
if (!recovered) {
fileTransferStorageIoFailed();
fileTransferFail("cannot recover map tile backup");
return;
}
}
s_file_transfer_file = storage.open(kFileTransferTemp, FILE_WRITE);
markSdIo();
if (!s_file_transfer_file) {
fileTransferStorageIoFailed();
fileTransferFail("cannot create temporary file");
return;
}
snprintf(s_file_transfer_name, sizeof s_file_transfer_name, "%s", name);
s_file_transfer_expected = static_cast<uint32_t>(declared);
s_file_transfer_received = 0;
s_file_transfer_crc = 0xFFFFFFFFu;
s_file_transfer_result[0] = '\0';
s_file_transfer_uploading = true;
fileTransferReply("READY");
fileTransferStartUpload(static_cast<uint32_t>(declared), canonical, final_path,
mode == 'R' ? FileTransferExistingMode::Replace
: FileTransferExistingMode::Skip,
true);
}
static void fileTransferChunk(const uint8_t* frame, size_t len) {
@@ -26140,16 +26273,47 @@ static void fileTransferEnd(const char* command) {
s_file_transfer_file.close();
markSdIo();
fs::FS& storage = fileTransferStorage();
if (s_file_transfer_map_upload) {
File tile = storage.open(kFileTransferTemp, FILE_READ);
uint8_t signature[8] = {0};
const bool valid_png = tile && tile.read(signature, sizeof signature) == sizeof signature &&
WebFileTransferProtocol::pngSignatureValid(signature, sizeof signature);
if (tile) tile.close();
markSdIo();
if (!valid_png) {
fileTransferFail("map tile is not a PNG file");
return;
}
}
char replace_backup[96] = {0};
bool existing_backed_up = false;
if (s_file_transfer_replace_existing && storage.exists(s_file_transfer_final)) {
snprintf(replace_backup, sizeof replace_backup, "%s.bak", s_file_transfer_final);
if (storage.exists(replace_backup) && !storage.remove(replace_backup)) {
fileTransferStorageIoFailed();
fileTransferFail("cannot clear map tile backup");
return;
}
if (!storage.rename(s_file_transfer_final, replace_backup)) {
fileTransferStorageIoFailed();
fileTransferFail("cannot replace existing map tile");
return;
}
existing_backed_up = true;
}
if (!storage.rename(kFileTransferTemp, s_file_transfer_final)) {
if (storage.exists(kFileTransferTemp)) storage.remove(kFileTransferTemp);
if (existing_backed_up) storage.rename(replace_backup, s_file_transfer_final);
fileTransferStorageIoFailed();
if (!storage.remove(kFileTransferTemp)) fileTransferStorageIoFailed();
fileTransferResetUpload(false);
snprintf(s_file_transfer_result, sizeof s_file_transfer_result, "%s", "Could not commit upload");
fileTransferReply("ERR could not commit upload");
return;
}
if (existing_backed_up) storage.remove(replace_backup); // stale backup is recovered on the next upload if this fails
markSdIo();
const uint32_t completed = s_file_transfer_received;
if (s_file_transfer_map_upload) s_file_transfer_map_dirty = true;
char completed_name[sizeof s_file_transfer_name];
snprintf(completed_name, sizeof completed_name, "%s", s_file_transfer_name);
fileTransferResetUpload(false);
@@ -26400,7 +26564,8 @@ static void webFileTransferTick() {
}
frame[len] = 0;
const char* command = reinterpret_cast<const char*>(frame);
if (strncmp(command, "BEGIN ", 6) == 0) fileTransferBegin(command);
if (strncmp(command, "MAPBEGIN ", 9) == 0) fileTransferBeginMap(command);
else if (strncmp(command, "BEGIN ", 6) == 0) fileTransferBegin(command);
else if (strncmp(command, "END ", 4) == 0) fileTransferEnd(command);
else if (strcmp(command, "LIST") == 0) fileTransferListBegin();
else if (strcmp(command, "LIST NEXT") == 0) fileTransferListNext();
@@ -26465,6 +26630,10 @@ static void closeFileTransferPage() {
if (s_file_transfer_root) { popupClose(&s_file_transfer_root); }
s_file_transfer_status = nullptr;
appPageEnd(&closeFileTransferPage);
if (s_file_transfer_map_dirty) {
s_file_transfer_map_dirty = false;
mapNoteStorageChanged();
}
}
static void fileTransferStopCb(lv_event_t* e) {
@@ -28887,6 +29056,55 @@ static bool s_tiles_fs_ready = false;
// so the core-0 fetch task and the core-1 render thread can't corrupt the SD.
static fs::FS* s_tile_fs = nullptr;
static char s_tile_root[16] = "";
#if CAP_MICROSD
static fs::FS* mapSdStorage() {
#if CAP_SD
return &SD;
#else
return &SD_MMC;
#endif
}
static bool mapSdStorageMounted() {
#if CAP_SD
return s_sd_mounted && SD.cardType() != CARD_NONE;
#else
return s_tan_sd_mounted && SD_MMC.cardType() != CARD_NONE;
#endif
}
static bool mapSdStorageReady() {
#if CAP_SD
return sdAdoptLiveMount() || fmSdTryMount();
#else
return tanSdTryMount();
#endif
}
static bool mapSdReadBlocked() {
#if CAP_SD
return s_sd_fail_note_ms != 0;
#else
return !s_tan_sd_mounted && millis() < s_tan_sd_retry_after;
#endif
}
static void mapSdReadFailed() {
#if CAP_SD
sdReadFailedCardDead();
#else
tanSdProbeAlive(true);
#endif
}
static void mapSdWriteFailed() {
#if CAP_SD
sdNoteIoFailure();
#else
s_map_sd_storage_changed = true;
#endif
}
#endif
// The cache backend for NON-microSD-tile mode (the LittleFS "tiles" partition, or
// the SD /tiles fallback when that partition is absent) — captured at boot so
// toggling microSD-tile mode off restores it. In microSD-tile mode the cache is
@@ -30660,16 +30878,16 @@ static void tileFetchTaskFn(void* arg) {
} else {
++s_tile_fetch_short_wr;
s_tile_fetch_last_wr = 'P'; // short/failed disk write (card full or SD error)
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
if (s_tile_fs == &SD) sdNoteIoFailure(); // wedge tell (worker task — stamp only)
#if CAP_MICROSD
if (s_tile_fs == mapSdStorage()) mapSdWriteFailed(); // wedge tell (worker task — stamp only)
#endif
}
}
} else {
++s_tile_fetch_open_fail;
s_tile_fetch_last_wr = 'O'; // open("w") failed: dir missing / write-protect / SD bus
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
if (s_tile_fs == &SD) sdNoteIoFailure(); // wedge tell (worker task — stamp only)
#if CAP_MICROSD
if (s_tile_fs == mapSdStorage()) mapSdWriteFailed(); // wedge tell (worker task — stamp only)
#endif
}
} else {
@@ -30842,7 +31060,7 @@ static void queueTileForFetch(uint8_t z, int32_t x, int32_t y) {
// internal cache while that card is absent, so maps stay online through the
// fallback until it returns. Boards without one keep the plain offline guard:
// no SD packs exist there, so microSD-tile mode can only mean "don't fetch".
#if CAP_SD || defined(TLORA_PAGER)
#if CAP_MICROSD
if (!s_tiles_fs_ready || !s_tile_fs) return;
#else
if (s_map_style == 0 && s_tiles_from_sd) return;
@@ -30988,15 +31206,16 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y,
char path[48];
snprintf(path, sizeof(path), "%s/%u/%ld/%ld.jpg",
mapTileRoot(), (unsigned)z, (long)x, (long)y);
#if CAP_SD || defined(TLORA_PAGER)
if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) { // SD packs are OSM-only; topo uses the online /tiles/topo cache
#if CAP_MICROSD
fs::FS* map_sd = mapSdStorage();
if (s_tiles_from_sd && s_tile_fs == map_sd && s_map_style == 0) { // SD packs are OSM-only; topo uses the online /tiles/topo cache
// Tile source = microSD: read straight off the card (fully offline, no server fetch).
if (s_sd_fail_note_ms) return false; // card suspected dead — don't stack per-tile SPI timeouts (sdHealthTick arbitrates)
if (mapSdReadBlocked()) return false; // card suspected dead — don't stack per-tile SPI timeouts (sdHealthTick arbitrates)
// Mounted check only — a render loop must never walk the multi-second
// mount ladder per tile (with the card out that read as recurring UI
// freezes). Mounting is owned by boot adoption, sdHealthTick's reinsert
// watch, the FM poll, and the SD-tiles toggle (mapOptTilesSdCb mounts).
if (!s_sd_mounted) return false;
if (!mapSdStorageMounted()) return false;
markSdIo(); // SD read activity -> status-bar LED
// Prefer the Meshtastic/MeshCore standard layout /maps/osm/{z}/{x}/{y}.png
// (decoded via lodepng); fall back to the legacy /tiles/{z}/{x}/{y}.jpg.
@@ -31018,7 +31237,7 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y,
for (int e = 0; e < 2 && !fsd; ++e) {
snprintf(ppath, sizeof(ppath), "%s/osm/%u/%ld/%ld.%s",
kMapRoots[r], (unsigned)z, (long)x, (long)y, kPngExt[e]);
fsd = SD.open(ppath, FILE_READ);
fsd = map_sd->open(ppath, FILE_READ);
}
}
// Plain /tiles/<z>/<x>/<y>.{png,PNG} — a PNG pack dropped straight into /tiles/
@@ -31026,11 +31245,11 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y,
// /maps/osm PNGs above; the .jpg fallback below covers JPEG /tiles/ packs.
if (!fsd) {
snprintf(ppath, sizeof(ppath), "/tiles/%u/%ld/%ld.png", (unsigned)z, (long)x, (long)y);
fsd = SD.open(ppath, FILE_READ);
fsd = map_sd->open(ppath, FILE_READ);
}
if (!fsd) {
snprintf(ppath, sizeof(ppath), "/tiles/%u/%ld/%ld.PNG", (unsigned)z, (long)x, (long)y);
fsd = SD.open(ppath, FILE_READ);
fsd = map_sd->open(ppath, FILE_READ);
}
// /tiles/<z>/<x>/<y>.jpg is the WRITABLE download cache (where the fetcher
// merges Wi-Fi tiles into the library), not a read-only pack — track that so
@@ -31038,17 +31257,17 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y,
// legs above are user-supplied packs and must never be removed.
bool sd_writable_cache = false;
if (!fsd) {
fsd = SD.open(path, FILE_READ);
fsd = map_sd->open(path, FILE_READ);
sd_writable_cache = (bool)fsd;
}
if (!fsd) { sdReadFailedCardDead(); return false; } // missing tile (cheap, silent) vs dead card (stamp + short-circuit)
if (!fsd) { mapSdReadFailed(); return false; } // missing tile (cheap, silent) vs dead card (stamp + short-circuit)
const size_t szsd = fsd.size();
if (szsd == 0 || szsd > 256 * 1024) { fsd.close(); return false; } // PNG tiles run larger than JPEG
uint8_t* bufsd = (uint8_t*)lvglPsramAlloc(szsd);
if (!bufsd) { fsd.close(); return false; }
const size_t nsd = fsd.read(bufsd, szsd);
fsd.close();
if (nsd != szsd) { lvglPsramFree(bufsd); sdReadFailedCardDead(); return false; } // short read mid-tile = card died under us
if (nsd != szsd) { lvglPsramFree(bufsd); mapSdReadFailed(); return false; } // short read mid-tile = card died under us
// A garbled/partial download in the writable cache decodes to a blank/half
// tile and, because the decode failure path does not re-queue, stuck there
// until a manual "Reload visible tiles". Drop it so the render miss below
@@ -31058,7 +31277,7 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y,
(nsd < 4 || bufsd[0] != 0xFF || bufsd[1] != 0xD8 || bufsd[2] != 0xFF ||
bufsd[nsd - 2] != 0xFF || bufsd[nsd - 1] != 0xD9)) {
lvglPsramFree(bufsd);
SD.remove(path);
map_sd->remove(path);
#if defined(MULTI_TRANSPORT_COMPANION)
tileFetchForget(z, x, y);
#endif
@@ -31076,15 +31295,15 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y,
// re-downloaded forever and rendered nothing (#tiles). open() is the real existence test; read up to
// the 100 KB writer cap and use the ACTUAL bytes read. (S3 boards: f.size() works there, but this is
// equally correct — a transient 100 KB PSRAM buffer per tile, freed right after decode.)
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
// Launcher installs cache tiles on the raw SD (s_tile_fs == &SD) — same
// dead-card short-circuit as the SD-pack path above.
if (s_tile_fs == &SD && s_sd_fail_note_ms) return false;
#if CAP_MICROSD
// A removable-card cache uses the same dead-card short-circuit as the
// explicit offline-pack path above, regardless of SPI or SDMMC transport.
if (s_tile_fs == mapSdStorage() && mapSdReadBlocked()) return false;
#endif
File f = tileCacheOpen(path, "r");
if (!f) {
#if defined(HAS_TDECK_GT911) || defined(HAS_TDECK_PRO) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)
if (s_tile_fs == &SD) sdReadFailedCardDead();
#if CAP_MICROSD
if (s_tile_fs == mapSdStorage()) mapSdReadFailed();
#endif
return false;
}
@@ -31141,7 +31360,7 @@ static void freeMapTiles() {
// outage are retried instead of being remembered as in-flight forever
// * re-renders immediately when the map is the visible tab
static void mapNoteStorageChanged() {
#if CAP_SD || defined(TLORA_PAGER)
#if CAP_MICROSD
// A fetch snapshots its paths but still dereferences the global backend for
// every filesystem operation of the request, so the pointer may only move at
// a worker request boundary. The ownership handshake pauses dequeueing there;
@@ -31154,14 +31373,16 @@ static void mapNoteStorageChanged() {
// leaves the pointer stale: most callers (a remount that changed nothing, a
// reinsert while already on the card) need no swap at all, and those must
// still repaint below even while a fetch is in flight.
const bool wants_teardown = !s_sd_mounted && s_tile_fs == &SD;
const bool wants_adopt = s_sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready);
const bool wants_swap = wants_teardown || (wants_adopt && s_tile_fs != &SD);
fs::FS* map_sd = mapSdStorage();
const bool sd_mounted = mapSdStorageMounted();
const bool wants_teardown = !sd_mounted && s_tile_fs == map_sd;
const bool wants_adopt = sd_mounted && (s_tiles_from_sd || !s_tiles_fs_ready);
const bool wants_swap = wants_teardown || (wants_adopt && s_tile_fs != map_sd);
const bool may_swap = !wants_swap || tileBackendSwapTryBegin();
const bool swap_deferred =
!may_swap && wants_swap;
if (may_swap && wants_teardown) {
if (s_tile_fs_default && s_tile_fs_default != &SD) {
if (s_tile_fs_default && s_tile_fs_default != map_sd) {
// SD tile mode lost its card, but the dedicated LittleFS cache is still
// mounted. Keep maps online instead of disabling that healthy fallback.
s_tile_fs = s_tile_fs_default;
@@ -31179,10 +31400,10 @@ static void mapNoteStorageChanged() {
// Reinserted while SD mode is selected, or no internal backend exists.
// Preserve an existing LittleFS default so toggling SD mode off remains a
// valid fallback after any number of remove/reinsert cycles.
s_tile_fs = &SD;
s_tile_fs = map_sd;
s_tile_root[0] = '\0';
if (!s_tile_fs_default) {
s_tile_fs_default = &SD;
s_tile_fs_default = map_sd;
s_tile_root_default[0] = '\0';
}
s_tiles_fs_ready = true;
@@ -31191,11 +31412,14 @@ static void mapNoteStorageChanged() {
// notification must not clear a pause owned by card removal/remount or the
// health probe while that lifecycle operation is still draining consumers.
if (wants_swap && may_swap) tileBackendSwapFinish();
#if !CAP_SD
s_map_sd_storage_changed = swap_deferred;
#endif
#endif
for (int i = 0; i < k_tile_fetch_dedup_size; ++i) s_tile_fetch_dedup[i] = 0;
s_tile_fetch_dedup_head = 0;
freeMapTiles();
#if CAP_SD || defined(TLORA_PAGER)
#if CAP_MICROSD
// Drop the stale tiles above either way, but do not paint through a pointer
// we already know is wrong — that would probe &SD for a card that is gone and
// pay a full SPI timeout per tile on the loop task. Only skip when a swap was
@@ -31209,10 +31433,11 @@ static void mapNoteStorageChanged() {
#if defined(ESP32)
// Is *some* tile source available to probe at all? (SD pack or LittleFS /tiles.)
static bool mapTileSourceReady() {
#if CAP_SD || defined(TLORA_PAGER)
#if CAP_MICROSD
// Mirror loadTileJpeg's source selection: SD packs are OSM-only, so topo
// reads the generic /tiles/topo cache and its readiness is s_tiles_fs_ready.
if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) return s_sd_mounted;
if (s_tiles_from_sd && s_tile_fs == mapSdStorage() && s_map_style == 0)
return mapSdStorageMounted();
#endif
return s_tiles_fs_ready;
}
@@ -31222,10 +31447,11 @@ static bool mapTileSourceReady() {
// online cache), so an SD /maps/osm offline pack was invisible to zoom — the loader
// drew its tiles but the buttons reported "Max/Min zoom for this pack" offline.
static bool tileExistsAt(uint8_t z, long x, long y) {
#if CAP_SD || defined(TLORA_PAGER)
if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) { // topo isn't on SD packs — probe the online cache below
if (s_sd_fail_note_ms) return false; // card suspected dead — skip the five per-tile probes (sdHealthTick arbitrates)
if (!s_sd_mounted) return false; // never ladder from the zoom guard (see loadTileJpeg)
#if CAP_MICROSD
fs::FS* map_sd = mapSdStorage();
if (s_tiles_from_sd && s_tile_fs == map_sd && s_map_style == 0) { // topo isn't on SD packs — probe the online cache below
if (mapSdReadBlocked()) return false; // card suspected dead — skip the five per-tile probes (sdHealthTick arbitrates)
if (!mapSdStorageMounted()) return false; // never ladder from the zoom guard (see loadTileJpeg)
char p[56];
// Both directory spellings, upper first: our own card bootstrap creates "/MAPS"
// while this only ever probed "/maps" (#286). Must stay in step with the loader in
@@ -31233,16 +31459,16 @@ static bool tileExistsAt(uint8_t z, long x, long y) {
for (const char* root : { "/MAPS", "/maps" }) {
for (const char* ext : { "png", "PNG" }) {
snprintf(p, sizeof p, "%s/osm/%u/%ld/%ld.%s", root, (unsigned)z, x, y, ext);
if (SD.exists(p)) return true;
if (map_sd->exists(p)) return true;
}
}
snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.png", (unsigned)z, x, y); // PNG pack in /tiles/
if (SD.exists(p)) return true;
if (map_sd->exists(p)) return true;
snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.PNG", (unsigned)z, x, y);
if (SD.exists(p)) return true;
if (map_sd->exists(p)) return true;
snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.jpg", (unsigned)z, x, y);
if (SD.exists(p)) return true;
sdReadFailedCardDead(); // all five missing: either past the pack edge (cheap, silent) or the card died
if (map_sd->exists(p)) return true;
mapSdReadFailed(); // all five missing: either past the pack edge (cheap, silent) or the card died
return false;
}
#endif
@@ -31597,11 +31823,11 @@ static void renderMapTiles() {
}
#if defined(ESP32)
#if CAP_SD || defined(TLORA_PAGER)
#if CAP_MICROSD
// s_map_style == 0: SD packs are OSM-only. In topo the loader reads the
// online /tiles/topo cache, so pointing the user at /maps/osm would be wrong
// and would hide the download progress/diagnostics below.
if (s_tiles_from_sd && s_tile_fs == &SD && s_map_style == 0) {
if (s_tiles_from_sd && s_tile_fs == mapSdStorage() && s_map_style == 0) {
lv_label_set_text(s_map_status_lbl,
TR("Map tiles: microSD\n\n"
"/maps/osm/z/x/y.png\n"
@@ -31651,14 +31877,10 @@ static void renderMapTiles() {
"ok %u fail %u http %d wr %c\n"
"open-fail %u short-wr %u\n\n"
"Keep Wi-Fi connected.\nTiles appear as they arrive.",
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
(s_tile_fs == &SD_MMC ? "SD cache" : "flash cache"),
#else
#if CAP_SD || defined(TLORA_PAGER)
(s_tile_fs == &SD ? "SD cache" : "flash cache"),
#if CAP_MICROSD
(s_tile_fs == mapSdStorage() ? "SD cache" : "flash cache"),
#else
(s_tile_root[0] ? "SD cache" : "flash cache"),
#endif
#endif
(unsigned)s_tile_fetch_ok, (unsigned)s_tile_fetch_failed,
(int)s_tile_fetch_last_code, (char)s_tile_fetch_last_wr,
@@ -32734,7 +32956,7 @@ static void mapOptZoomButtonsCb(lv_event_t* e) {
mapZoomControlsApply();
}
#if CAP_SD || defined(TLORA_PAGER)
#if CAP_MICROSD
// Map tile source toggle (in the map options popup): ON = tiles live on the microSD
// card — read the user's SD library AND cache Wi-Fi-fetched gaps there (#20), so the
// library grows and downloads survive; OFF = tile server + internal LittleFS cache.
@@ -32761,14 +32983,18 @@ static void mapOptTilesSdCb(lv_event_t* e) {
// the SD reader looks), so fetched tiles merge with the library; OFF -> the boot
// default backend (LittleFS partition / SD fallback).
if (on) {
if (fmSdTryMount() || SD.cardType() != CARD_NONE) {
s_tile_fs = &SD;
if (mapSdStorageReady()) {
s_tile_fs = mapSdStorage();
s_tile_root[0] = '\0';
s_tiles_fs_ready = true;
if (!s_tile_fs_default) {
s_tile_fs_default = &SD;
s_tile_fs_default = mapSdStorage();
s_tile_root_default[0] = '\0';
}
} else {
s_tiles_from_sd = false;
touchPrefsSetTilesFromSd(false);
lv_obj_clear_state(lv_event_get_target(e), LV_STATE_CHECKED);
}
} else {
s_tile_fs = s_tile_fs_default;
@@ -32777,7 +33003,10 @@ static void mapOptTilesSdCb(lv_event_t* e) {
freeMapTiles(); // drop stale tile widgets → reload from the new source
renderMapTiles();
tileBackendSwapFinish(); // queued requests resume against the selected backend
if (g_lv.task) g_lv.task->showAlert(on ? TR("Map tiles: microSD") : TR("Map tiles: server"), 1400);
if (g_lv.task) {
if (on && !s_tiles_from_sd) g_lv.task->showAlert(TR("SD card unavailable"), 1600);
else g_lv.task->showAlert(on ? TR("Map tiles: microSD") : TR("Map tiles: server"), 1400);
}
}
#endif
@@ -32980,7 +33209,7 @@ static void openMapOptions() {
lv_obj_set_pos(title, 0, 0);
int y = 26;
#if CAP_SD || defined(TLORA_PAGER)
#if CAP_MICROSD
// Row: tile source — microSD (offline) vs the tile server. The important one,
// so it sits at the very top.
{
@@ -55946,7 +56175,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
// mounted by main.cpp at boot — XL9535 SD_EN must be LOW, it gates the slot's power); else the
// internal 8 MB FFat 'storage' partition — a standard FFat mount of OUR OWN partition, so none of
// the Tanmatsu locfd metadata quirks apply. Either way the map gets a working Wi-Fi tile cache.
else if (SD_MMC.cardType() != CARD_NONE) {
else if (tanSdTryMount()) {
s_tile_fs = &SD_MMC;
s_tile_root[0] = '\0';
s_tiles_fs_ready = true;
@@ -55965,7 +56194,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
}
#endif
#if defined(HAS_WIO_TRACKER_L2)
else if (SD_MMC.cardType() != CARD_NONE) {
else if (tanSdTryMount()) {
s_tile_fs = &SD_MMC;
s_tile_root[0] = '\0';
s_tiles_fs_ready = true;
@@ -55984,13 +56213,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
// mirrors this when the toggle flips at runtime.
s_tile_fs_default = s_tile_fs;
strncpy(s_tile_root_default, s_tile_root, sizeof s_tile_root_default - 1);
#if CAP_SD || defined(TLORA_PAGER)
if (s_tiles_from_sd && (sdAdoptLiveMount() || fmSdTryMount())) {
s_tile_fs = &SD;
#if CAP_MICROSD
if (s_tiles_from_sd && mapSdStorageReady()) {
s_tile_fs = mapSdStorage();
s_tile_root[0] = '\0';
s_tiles_fs_ready = true;
if (!s_tile_fs_default) {
s_tile_fs_default = &SD;
s_tile_fs_default = mapSdStorage();
s_tile_root_default[0] = '\0';
}
WIRE_DBG("[TILE] microSD-tile mode -> caching Wi-Fi tiles on SD /tiles (merges with library)");
@@ -59574,6 +59803,15 @@ void UITask::loop() {
else if (g_lv.ch.detail_open) jumpBtnsSetDim(&g_lv.ch, true);
else s_jump_dimmed = true; // nothing open: park the state
}
#if CAP_MICROSD && !CAP_SD
// SD_MMC failures are discovered inside map/file I/O. Repoint the tile
// backend here, outside those operations, and retry until the tile worker
// reaches a safe request boundary.
if (s_map_sd_storage_changed) {
if (s_tan_sd_mounted) tanSdProbeAlive(true);
mapNoteStorageChanged();
}
#endif
#if CAP_SD || defined(TLORA_PAGER)
sdHealthTick(); // wedge detect + remount, driven by writer-flagged failures (any task)
// microSD insert/remove detection — only while the file manager is open, so
+23
View File
@@ -48,6 +48,29 @@ int main() {
long_name[65] = '\0';
assert(!fileNameValid(long_name));
MapTilePath tile = {};
assert(mapTilePathValid("3/0/0.png", &tile));
assert(tile.zoom == 3 && tile.x == 0 && tile.y == 0);
assert(mapTilePathValid("15/17642/10765.png", &tile));
assert(tile.zoom == 15 && tile.x == 17642 && tile.y == 10765);
assert(mapTilePathValid("19/524287/524287.PNG", &tile));
assert(!mapTilePathValid("2/0/0.png"));
assert(!mapTilePathValid("20/0/0.png"));
assert(!mapTilePathValid("3/8/0.png"));
assert(!mapTilePathValid("3/0/8.png"));
assert(!mapTilePathValid("15/x/10765.png"));
assert(!mapTilePathValid("15/17642/-1.png"));
assert(!mapTilePathValid("15/17642/10765.jpg"));
assert(!mapTilePathValid("/15/17642/10765.png"));
assert(!mapTilePathValid("15/17642/../10765.png"));
assert(!mapTilePathValid("15/17642/10765.png/extra"));
const uint8_t png_signature[] = { 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n' };
const uint8_t jpeg_signature[] = { 0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0 };
assert(pngSignatureValid(png_signature, sizeof png_signature));
assert(!pngSignatureValid(png_signature, sizeof png_signature - 1));
assert(!pngSignatureValid(jpeg_signature, sizeof jpeg_signature));
assert(readablePath("/screenshots/capture.png"));
assert(readablePath("/transfer/archive.bin"));
assert(readablePath("/wadamesh-crash.elf"));