feat: upload offline maps to SD
This commit is contained in:
1 parent
08ad95c5c2
commit
e83bb7582e
7 files changed
+483
-108
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user