Claude: File chunking system
This commit is contained in:
committed by
Rune Harlyk
parent
725d62747d
commit
f440fa3973
@@ -0,0 +1,211 @@
|
|||||||
|
# Chunked Filesystem Transfer System
|
||||||
|
|
||||||
|
This system enables chunked file uploads and downloads between the client (web browser) and ESP32 over WebSocket, overcoming the 1KB per stream limitation.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Protocol Messages (Protobuf)
|
||||||
|
|
||||||
|
The system uses Protocol Buffers for efficient binary messaging with the following operations:
|
||||||
|
|
||||||
|
1. **Delete**: Remove files or directories
|
||||||
|
2. **Mkdir**: Create directories
|
||||||
|
3. **List**: List files and directories
|
||||||
|
4. **Download**: Transfer files from ESP32 to client (chunked)
|
||||||
|
5. **Upload**: Transfer files from client to ESP32 (chunked)
|
||||||
|
6. **Cancel**: Cancel ongoing transfers
|
||||||
|
|
||||||
|
### Chunked Transfer Flow
|
||||||
|
|
||||||
|
#### Download (ESP32 → Client)
|
||||||
|
|
||||||
|
1. Client sends `FSDownloadStartRequest` with file path
|
||||||
|
2. ESP32 responds with `FSDownloadStartResponse` containing:
|
||||||
|
- Transfer ID
|
||||||
|
- File size
|
||||||
|
- Chunk size (1024 bytes max)
|
||||||
|
- Total chunks
|
||||||
|
3. Client requests chunks sequentially using `FSDownloadChunkRequest`
|
||||||
|
4. ESP32 sends each chunk via `FSDownloadChunkResponse`
|
||||||
|
5. Transfer completes when last chunk is received
|
||||||
|
|
||||||
|
#### Upload (Client → ESP32)
|
||||||
|
|
||||||
|
1. Client sends `FSUploadStartRequest` with destination path and file size
|
||||||
|
2. ESP32 responds with `FSUploadStartResponse` containing:
|
||||||
|
- Transfer ID
|
||||||
|
- Max chunk size (1024 bytes)
|
||||||
|
3. Client sends chunks sequentially using `FSUploadChunkRequest`
|
||||||
|
4. ESP32 responds with `FSUploadChunkResponse` after each chunk
|
||||||
|
5. Transfer completes when last chunk is written
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### ESP32 Side
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `esp32/include/filesystem_ws.h` - Header file with handler class definition
|
||||||
|
- `esp32/src/filesystem_ws.cpp` - Implementation of filesystem operations
|
||||||
|
- `esp32/include/communication/proto_helpers.h` - Message traits for protobuf
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- Transfer state management with automatic cleanup
|
||||||
|
- Timeout handling (30 seconds of inactivity)
|
||||||
|
- Recursive directory deletion
|
||||||
|
- File integrity verification
|
||||||
|
|
||||||
|
**Integration:**
|
||||||
|
You need to integrate the filesystem handlers into your WebSocket message handling. In your main WebSocket handler, add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include <filesystem_ws.h>
|
||||||
|
|
||||||
|
// In your correlation request handler:
|
||||||
|
void handleCorrelationRequest(const socket_message_CorrelationRequest& request, int clientId) {
|
||||||
|
socket_message_CorrelationResponse response;
|
||||||
|
|
||||||
|
// ... existing handlers ...
|
||||||
|
|
||||||
|
if (request.which_request == socket_message_CorrelationRequest_fs_delete_request_tag) {
|
||||||
|
response.which_response = socket_message_CorrelationResponse_fs_delete_response_tag;
|
||||||
|
response.response.fs_delete_response =
|
||||||
|
FileSystemWS::fsHandler.handleDelete(request.request.fs_delete_request);
|
||||||
|
}
|
||||||
|
else if (request.which_request == socket_message_CorrelationRequest_fs_mkdir_request_tag) {
|
||||||
|
response.which_response = socket_message_CorrelationResponse_fs_mkdir_response_tag;
|
||||||
|
response.response.fs_mkdir_response =
|
||||||
|
FileSystemWS::fsHandler.handleMkdir(request.request.fs_mkdir_request);
|
||||||
|
}
|
||||||
|
else if (request.which_request == socket_message_CorrelationRequest_fs_list_request_tag) {
|
||||||
|
response.which_response = socket_message_CorrelationResponse_fs_list_response_tag;
|
||||||
|
response.response.fs_list_response =
|
||||||
|
FileSystemWS::fsHandler.handleList(request.request.fs_list_request);
|
||||||
|
}
|
||||||
|
else if (request.which_request == socket_message_CorrelationRequest_fs_download_start_request_tag) {
|
||||||
|
response.which_response = socket_message_CorrelationResponse_fs_download_start_response_tag;
|
||||||
|
response.response.fs_download_start_response =
|
||||||
|
FileSystemWS::fsHandler.handleDownloadStart(request.request.fs_download_start_request);
|
||||||
|
}
|
||||||
|
else if (request.which_request == socket_message_CorrelationRequest_fs_download_chunk_request_tag) {
|
||||||
|
response.which_response = socket_message_CorrelationResponse_fs_download_chunk_response_tag;
|
||||||
|
response.response.fs_download_chunk_response =
|
||||||
|
FileSystemWS::fsHandler.handleDownloadChunk(request.request.fs_download_chunk_request);
|
||||||
|
}
|
||||||
|
else if (request.which_request == socket_message_CorrelationRequest_fs_upload_start_request_tag) {
|
||||||
|
response.which_response = socket_message_CorrelationResponse_fs_upload_start_response_tag;
|
||||||
|
response.response.fs_upload_start_response =
|
||||||
|
FileSystemWS::fsHandler.handleUploadStart(request.request.fs_upload_start_request);
|
||||||
|
}
|
||||||
|
else if (request.which_request == socket_message_CorrelationRequest_fs_upload_chunk_request_tag) {
|
||||||
|
response.which_response = socket_message_CorrelationResponse_fs_upload_chunk_response_tag;
|
||||||
|
response.response.fs_upload_chunk_response =
|
||||||
|
FileSystemWS::fsHandler.handleUploadChunk(request.request.fs_upload_chunk_request);
|
||||||
|
}
|
||||||
|
else if (request.which_request == socket_message_CorrelationRequest_fs_cancel_transfer_request_tag) {
|
||||||
|
response.which_response = socket_message_CorrelationResponse_fs_cancel_transfer_response_tag;
|
||||||
|
response.response.fs_cancel_transfer_response =
|
||||||
|
FileSystemWS::fsHandler.handleCancelTransfer(request.request.fs_cancel_transfer_request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send response back to client
|
||||||
|
sendCorrelationResponse(response, clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally, in your main loop or timer:
|
||||||
|
void loop() {
|
||||||
|
// Clean up expired transfers periodically
|
||||||
|
FileSystemWS::fsHandler.cleanupExpiredTransfers();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Side (TypeScript/Svelte)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `app/src/lib/filesystem/chunkedTransfer.ts` - Client library for file transfers
|
||||||
|
- `app/src/lib/components/filesystem/FileManager.svelte` - Example UI component
|
||||||
|
|
||||||
|
**Usage Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { fileSystemClient } from '$lib/filesystem/chunkedTransfer'
|
||||||
|
|
||||||
|
// Upload a file
|
||||||
|
const file = new File(['Hello World'], 'test.txt')
|
||||||
|
const result = await fileSystemClient.uploadFileFromBrowser('/test.txt', file, (progress) => {
|
||||||
|
console.log(`Upload: ${progress.percentage}%`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Download a file
|
||||||
|
const download = await fileSystemClient.downloadFileAndSave(
|
||||||
|
'/test.txt',
|
||||||
|
'test.txt',
|
||||||
|
(progress) => {
|
||||||
|
console.log(`Download: ${progress.percentage}%`)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// List directory
|
||||||
|
const listing = await fileSystemClient.listDirectory('/')
|
||||||
|
console.log('Files:', listing.files)
|
||||||
|
console.log('Directories:', listing.directories)
|
||||||
|
|
||||||
|
// Create directory
|
||||||
|
await fileSystemClient.createDirectory('/new_folder')
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
await fileSystemClient.deleteFile('/old_file.txt')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Maximum Chunk Size
|
||||||
|
|
||||||
|
Currently set to 1024 bytes (`FS_MAX_CHUNK_SIZE`) to work within ESP32 WebSocket frame limitations. Adjust if your setup allows larger frames.
|
||||||
|
|
||||||
|
### Transfer Timeout
|
||||||
|
|
||||||
|
Transfers inactive for 30 seconds (`FS_TRANSFER_TIMEOUT`) are automatically cleaned up. Increase for slower connections.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Network errors: Transfers are automatically cancelled
|
||||||
|
- Timeouts: Inactive transfers are cleaned up on ESP32
|
||||||
|
- File errors: Detailed error messages returned to client
|
||||||
|
- Partial uploads: Cancelled uploads delete the partial file on ESP32
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- **Sequential Chunks**: Chunks are sent sequentially to ensure order and reliability
|
||||||
|
- **Memory Usage**: ESP32 keeps one File handle open per active transfer
|
||||||
|
- **Browser Memory**: Downloads buffer entire file in memory before saving
|
||||||
|
- **Network**: ~1KB per message overhead due to protobuf encoding
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- No authentication/authorization implemented - add as needed
|
||||||
|
- Path traversal: Validate paths to prevent access outside allowed directories
|
||||||
|
- File size limits: Consider adding max file size restrictions
|
||||||
|
- Rate limiting: Consider limiting concurrent transfers per client
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
1. Build and flash the ESP32 firmware
|
||||||
|
2. Run the web application
|
||||||
|
3. Navigate to the FileManager component
|
||||||
|
4. Test upload/download with files of various sizes
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Transfer fails midway:**
|
||||||
|
- Check WebSocket connection stability
|
||||||
|
- Verify ESP32 has sufficient filesystem space
|
||||||
|
- Check for timeout issues
|
||||||
|
|
||||||
|
**Upload creates corrupted files:**
|
||||||
|
- Verify chunk order is preserved
|
||||||
|
- Check for protobuf encoding/decoding errors
|
||||||
|
|
||||||
|
**ESP32 runs out of memory:**
|
||||||
|
- Reduce number of concurrent transfers
|
||||||
|
- Close File handles properly after transfers
|
||||||
|
- Run cleanup more frequently
|
||||||
Vendored
+6
-6
@@ -1,8 +1,8 @@
|
|||||||
declare module 'app-env' {
|
declare module "app-env" {
|
||||||
interface ENV {
|
interface ENV {
|
||||||
VITE_USE_HOST_NAME: boolean
|
VITE_USE_HOST_NAME: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appEnv: ENV
|
const appEnv: ENV;
|
||||||
export default appEnv
|
export default appEnv;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fileSystemClient, type TransferProgress } from '$lib/filesystem/chunkedTransfer'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
|
let currentPath = '/'
|
||||||
|
let files: Array<{ name: string; size: number }> = []
|
||||||
|
let directories: Array<{ name: string }> = []
|
||||||
|
let loading = false
|
||||||
|
let error = ''
|
||||||
|
let uploadProgress: TransferProgress | null = null
|
||||||
|
let downloadProgress: TransferProgress | null = null
|
||||||
|
|
||||||
|
async function loadDirectory() {
|
||||||
|
loading = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
const result = await fileSystemClient.listDirectory(currentPath)
|
||||||
|
if (result.success) {
|
||||||
|
files = result.files
|
||||||
|
directories = result.directories
|
||||||
|
} else {
|
||||||
|
error = result.error || 'Failed to load directory'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateTo(path: string) {
|
||||||
|
currentPath = path
|
||||||
|
await loadDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
uploadProgress = null
|
||||||
|
error = ''
|
||||||
|
|
||||||
|
const destinationPath = currentPath.endsWith('/')
|
||||||
|
? currentPath + file.name
|
||||||
|
: currentPath + '/' + file.name
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fileSystemClient.uploadFileFromBrowser(
|
||||||
|
destinationPath,
|
||||||
|
file,
|
||||||
|
(progress) => {
|
||||||
|
uploadProgress = progress
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await loadDirectory()
|
||||||
|
} else {
|
||||||
|
error = result.error || 'Upload failed'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Upload error'
|
||||||
|
} finally {
|
||||||
|
uploadProgress = null
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload(filename: string) {
|
||||||
|
downloadProgress = null
|
||||||
|
error = ''
|
||||||
|
|
||||||
|
const filePath = currentPath.endsWith('/')
|
||||||
|
? currentPath + filename
|
||||||
|
: currentPath + '/' + filename
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fileSystemClient.downloadFileAndSave(filePath, filename, (progress) => {
|
||||||
|
downloadProgress = progress
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
error = result.error || 'Download failed'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Download error'
|
||||||
|
} finally {
|
||||||
|
downloadProgress = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(name: string, isDirectory: boolean) {
|
||||||
|
if (!confirm(`Delete ${isDirectory ? 'directory' : 'file'} "${name}"?`)) return
|
||||||
|
|
||||||
|
error = ''
|
||||||
|
const path = currentPath.endsWith('/') ? currentPath + name : currentPath + '/' + name
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fileSystemClient.deleteFile(path)
|
||||||
|
if (result.success) {
|
||||||
|
await loadDirectory()
|
||||||
|
} else {
|
||||||
|
error = result.error || 'Delete failed'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Delete error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateDirectory() {
|
||||||
|
const name = prompt('Enter directory name:')
|
||||||
|
if (!name) return
|
||||||
|
|
||||||
|
error = ''
|
||||||
|
const path = currentPath.endsWith('/') ? currentPath + name : currentPath + '/' + name
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fileSystemClient.createDirectory(path)
|
||||||
|
if (result.success) {
|
||||||
|
await loadDirectory()
|
||||||
|
} else {
|
||||||
|
error = result.error || 'Failed to create directory'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Error creating directory'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadDirectory()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="file-manager">
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2>File Manager</h2>
|
||||||
|
<div class="path">Current: {currentPath}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button on:click={handleCreateDirectory}>New Folder</button>
|
||||||
|
<label class="upload-btn">
|
||||||
|
Upload File
|
||||||
|
<input type="file" on:change={handleFileUpload} style="display: none;" />
|
||||||
|
</label>
|
||||||
|
<button on:click={loadDirectory}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if uploadProgress}
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-label">
|
||||||
|
Uploading: {uploadProgress.percentage.toFixed(1)}% ({formatBytes(
|
||||||
|
uploadProgress.bytesTransferred
|
||||||
|
)} / {formatBytes(uploadProgress.totalBytes)})
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {uploadProgress.percentage}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if downloadProgress}
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-label">
|
||||||
|
Downloading: {downloadProgress.percentage.toFixed(1)}% ({formatBytes(
|
||||||
|
downloadProgress.bytesTransferred
|
||||||
|
)} / {formatBytes(downloadProgress.totalBytes)})
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {downloadProgress.percentage}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="file-list">
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Loading...</div>
|
||||||
|
{:else}
|
||||||
|
{#if currentPath !== '/'}
|
||||||
|
<div class="file-item directory" on:click={() => navigateTo('/')}>
|
||||||
|
<span class="icon">📁</span>
|
||||||
|
<span class="name">..</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each directories as dir}
|
||||||
|
<div class="file-item directory">
|
||||||
|
<span class="icon">📁</span>
|
||||||
|
<span class="name" on:click={() => navigateTo(currentPath + '/' + dir.name)}
|
||||||
|
>{dir.name}</span
|
||||||
|
>
|
||||||
|
<button class="delete-btn" on:click={() => handleDelete(dir.name, true)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each files as file}
|
||||||
|
<div class="file-item">
|
||||||
|
<span class="icon">📄</span>
|
||||||
|
<span class="name">{file.name}</span>
|
||||||
|
<span class="size">{formatBytes(file.size)}</span>
|
||||||
|
<button class="download-btn" on:click={() => handleDownload(file.name)}>Download</button>
|
||||||
|
<button class="delete-btn" on:click={() => handleDelete(file.name, false)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if files.length === 0 && directories.length === 0}
|
||||||
|
<div class="empty">Directory is empty</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.file-manager {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar h2 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path {
|
||||||
|
font-family: monospace;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button,
|
||||||
|
.upload-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button:hover,
|
||||||
|
.upload-btn:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 20px;
|
||||||
|
background: #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #28a745;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.directory {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn,
|
||||||
|
.delete-btn {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { socket } from '$lib/stores/socket'
|
||||||
|
import * as Messages from '$lib/platform_shared/message'
|
||||||
|
import type {
|
||||||
|
FSDeleteRequest,
|
||||||
|
FSMkdirRequest,
|
||||||
|
FSListRequest,
|
||||||
|
FSDownloadStartRequest,
|
||||||
|
FSDownloadChunkRequest,
|
||||||
|
FSUploadStartRequest,
|
||||||
|
FSUploadChunkRequest,
|
||||||
|
FSCancelTransferRequest,
|
||||||
|
CorrelationResponse
|
||||||
|
} from '$lib/platform_shared/message'
|
||||||
|
|
||||||
|
const MAX_CHUNK_SIZE = 1024
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryInfo {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListResult {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
files: FileInfo[]
|
||||||
|
directories: DirectoryInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferProgress {
|
||||||
|
transferId: string
|
||||||
|
bytesTransferred: number
|
||||||
|
totalBytes: number
|
||||||
|
chunksCompleted: number
|
||||||
|
totalChunks: number
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgressCallback = (progress: TransferProgress) => void
|
||||||
|
|
||||||
|
export class FileSystemClient {
|
||||||
|
/**
|
||||||
|
* Delete a file or directory on the ESP32
|
||||||
|
*/
|
||||||
|
async deleteFile(path: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const request: FSDeleteRequest = { path }
|
||||||
|
|
||||||
|
const response = await socket.request({
|
||||||
|
fsDeleteRequest: request
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.fsDeleteResponse) {
|
||||||
|
return {
|
||||||
|
success: response.fsDeleteResponse.success,
|
||||||
|
error: response.fsDeleteResponse.error || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: 'No response received' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a directory on the ESP32
|
||||||
|
*/
|
||||||
|
async createDirectory(path: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const request: FSMkdirRequest = { path }
|
||||||
|
|
||||||
|
const response = await socket.request({
|
||||||
|
fsMkdirRequest: request
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.fsMkdirResponse) {
|
||||||
|
return {
|
||||||
|
success: response.fsMkdirResponse.success,
|
||||||
|
error: response.fsMkdirResponse.error || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: 'No response received' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files and directories at the given path
|
||||||
|
*/
|
||||||
|
async listDirectory(path: string = '/'): Promise<ListResult> {
|
||||||
|
const request: FSListRequest = { path }
|
||||||
|
|
||||||
|
const response = await socket.request({
|
||||||
|
fsListRequest: request
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.fsListResponse) {
|
||||||
|
const resp = response.fsListResponse
|
||||||
|
return {
|
||||||
|
success: resp.success,
|
||||||
|
error: resp.error || undefined,
|
||||||
|
files: (resp.files || []).map((f) => ({ name: f.name, size: f.size })),
|
||||||
|
directories: (resp.directories || []).map((d) => ({ name: d.name }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: 'No response received', files: [], directories: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from the ESP32
|
||||||
|
*/
|
||||||
|
async downloadFile(
|
||||||
|
path: string,
|
||||||
|
onProgress?: ProgressCallback
|
||||||
|
): Promise<{ success: boolean; data?: Uint8Array; error?: string }> {
|
||||||
|
// Start download
|
||||||
|
const startRequest: FSDownloadStartRequest = { path }
|
||||||
|
|
||||||
|
const startResponse = await socket.request({
|
||||||
|
fsDownloadStartRequest: startRequest
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!startResponse.fsDownloadStartResponse) {
|
||||||
|
return { success: false, error: 'Failed to start download' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const startResp = startResponse.fsDownloadStartResponse
|
||||||
|
|
||||||
|
if (!startResp.success) {
|
||||||
|
return { success: false, error: startResp.error || 'Failed to start download' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferId = startResp.transferId
|
||||||
|
const totalChunks = startResp.totalChunks
|
||||||
|
const fileSize = startResp.fileSize
|
||||||
|
|
||||||
|
// Allocate buffer for entire file
|
||||||
|
const buffer = new Uint8Array(fileSize)
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
// Download chunks sequentially
|
||||||
|
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||||
|
const chunkRequest: FSDownloadChunkRequest = {
|
||||||
|
transferId,
|
||||||
|
chunkIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkResponse = await socket.request({
|
||||||
|
fsDownloadChunkRequest: chunkRequest
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!chunkResponse.fsDownloadChunkResponse) {
|
||||||
|
await this.cancelTransfer(transferId)
|
||||||
|
return { success: false, error: `Failed to download chunk ${chunkIndex}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkResp = chunkResponse.fsDownloadChunkResponse
|
||||||
|
|
||||||
|
if (chunkResp.error) {
|
||||||
|
await this.cancelTransfer(transferId)
|
||||||
|
return { success: false, error: chunkResp.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy chunk data to buffer
|
||||||
|
if (chunkResp.data) {
|
||||||
|
buffer.set(chunkResp.data, offset)
|
||||||
|
offset += chunkResp.data.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report progress
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
transferId,
|
||||||
|
bytesTransferred: offset,
|
||||||
|
totalBytes: fileSize,
|
||||||
|
chunksCompleted: chunkIndex + 1,
|
||||||
|
totalChunks,
|
||||||
|
percentage: ((chunkIndex + 1) / totalChunks) * 100
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: buffer }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to the ESP32
|
||||||
|
*/
|
||||||
|
async uploadFile(
|
||||||
|
path: string,
|
||||||
|
data: Uint8Array,
|
||||||
|
onProgress?: ProgressCallback
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const fileSize = data.length
|
||||||
|
const chunkSize = MAX_CHUNK_SIZE
|
||||||
|
const totalChunks = Math.ceil(fileSize / chunkSize)
|
||||||
|
|
||||||
|
// Start upload
|
||||||
|
const startRequest: FSUploadStartRequest = {
|
||||||
|
path,
|
||||||
|
fileSize,
|
||||||
|
chunkSize
|
||||||
|
}
|
||||||
|
|
||||||
|
const startResponse = await socket.request({
|
||||||
|
fsUploadStartRequest: startRequest
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!startResponse.fsUploadStartResponse) {
|
||||||
|
return { success: false, error: 'Failed to start upload' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const startResp = startResponse.fsUploadStartResponse
|
||||||
|
|
||||||
|
if (!startResp.success) {
|
||||||
|
return { success: false, error: startResp.error || 'Failed to start upload' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferId = startResp.transferId
|
||||||
|
const maxChunkSize = startResp.maxChunkSize || MAX_CHUNK_SIZE
|
||||||
|
|
||||||
|
// Upload chunks sequentially
|
||||||
|
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||||
|
const offset = chunkIndex * chunkSize
|
||||||
|
const end = Math.min(offset + chunkSize, fileSize)
|
||||||
|
const chunkData = data.slice(offset, end)
|
||||||
|
const isLast = chunkIndex === totalChunks - 1
|
||||||
|
|
||||||
|
const chunkRequest: FSUploadChunkRequest = {
|
||||||
|
transferId,
|
||||||
|
chunkIndex,
|
||||||
|
data: chunkData,
|
||||||
|
isLast
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkResponse = await socket.request({
|
||||||
|
fsUploadChunkRequest: chunkRequest
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!chunkResponse.fsUploadChunkResponse) {
|
||||||
|
await this.cancelTransfer(transferId)
|
||||||
|
return { success: false, error: `Failed to upload chunk ${chunkIndex}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkResp = chunkResponse.fsUploadChunkResponse
|
||||||
|
|
||||||
|
if (!chunkResp.success) {
|
||||||
|
await this.cancelTransfer(transferId)
|
||||||
|
return { success: false, error: chunkResp.error || 'Failed to upload chunk' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report progress
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
transferId,
|
||||||
|
bytesTransferred: end,
|
||||||
|
totalBytes: fileSize,
|
||||||
|
chunksCompleted: chunkIndex + 1,
|
||||||
|
totalChunks,
|
||||||
|
percentage: ((chunkIndex + 1) / totalChunks) * 100
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel an ongoing transfer
|
||||||
|
*/
|
||||||
|
async cancelTransfer(transferId: string): Promise<{ success: boolean }> {
|
||||||
|
const request: FSCancelTransferRequest = { transferId }
|
||||||
|
|
||||||
|
const response = await socket.request({
|
||||||
|
fsCancelTransferRequest: request
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.fsCancelTransferResponse) {
|
||||||
|
return { success: response.fsCancelTransferResponse.success }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Upload a File object from browser
|
||||||
|
*/
|
||||||
|
async uploadFileFromBrowser(
|
||||||
|
destinationPath: string,
|
||||||
|
file: File,
|
||||||
|
onProgress?: ProgressCallback
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
const data = new Uint8Array(arrayBuffer)
|
||||||
|
return this.uploadFile(destinationPath, data, onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Download a file and save it to browser
|
||||||
|
*/
|
||||||
|
async downloadFileAndSave(
|
||||||
|
path: string,
|
||||||
|
filename: string,
|
||||||
|
onProgress?: ProgressCallback
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const result = await this.downloadFile(path, onProgress)
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create blob and trigger download
|
||||||
|
const blob = new Blob([result.data])
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fileSystemClient = new FileSystemClient()
|
||||||
@@ -41,6 +41,22 @@ DEFINE_MESSAGE_TRAITS(ServoPWMData, servo_pwm)
|
|||||||
DEFINE_MESSAGE_TRAITS(ServoStateData, servo_state)
|
DEFINE_MESSAGE_TRAITS(ServoStateData, servo_state)
|
||||||
DEFINE_MESSAGE_TRAITS(CorrelationRequest, correlation_request)
|
DEFINE_MESSAGE_TRAITS(CorrelationRequest, correlation_request)
|
||||||
DEFINE_MESSAGE_TRAITS(CorrelationResponse, correlation_response)
|
DEFINE_MESSAGE_TRAITS(CorrelationResponse, correlation_response)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSDeleteRequest, fs_delete_request)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSDeleteResponse, fs_delete_response)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSMkdirRequest, fs_mkdir_request)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSMkdirResponse, fs_mkdir_response)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSListRequest, fs_list_request)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSListResponse, fs_list_response)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSDownloadStartRequest, fs_download_start_request)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSDownloadStartResponse, fs_download_start_response)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSDownloadChunkRequest, fs_download_chunk_request)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSDownloadChunkResponse, fs_download_chunk_response)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSUploadStartRequest, fs_upload_start_request)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSUploadStartResponse, fs_upload_start_response)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSUploadChunkRequest, fs_upload_chunk_request)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSUploadChunkResponse, fs_upload_chunk_response)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSCancelTransferRequest, fs_cancel_transfer_request)
|
||||||
|
DEFINE_MESSAGE_TRAITS(FSCancelTransferResponse, fs_cancel_transfer_response)
|
||||||
|
|
||||||
#undef DEFINE_MESSAGE_TRAITS
|
#undef DEFINE_MESSAGE_TRAITS
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <LittleFS.h>
|
||||||
|
#include <platform_shared/message.pb.h>
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#define FS_MAX_CHUNK_SIZE 1024
|
||||||
|
#define FS_TRANSFER_TIMEOUT 30000 // 30 seconds
|
||||||
|
|
||||||
|
namespace FileSystemWS {
|
||||||
|
|
||||||
|
struct TransferState {
|
||||||
|
std::string path;
|
||||||
|
File file;
|
||||||
|
uint32_t fileSize;
|
||||||
|
uint32_t chunkSize;
|
||||||
|
uint32_t totalChunks;
|
||||||
|
uint32_t chunksProcessed;
|
||||||
|
uint32_t lastActivityTime;
|
||||||
|
bool isUpload;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FileSystemHandler {
|
||||||
|
public:
|
||||||
|
FileSystemHandler();
|
||||||
|
|
||||||
|
// Delete file/directory
|
||||||
|
socket_message_FSDeleteResponse handleDelete(const socket_message_FSDeleteRequest& req);
|
||||||
|
|
||||||
|
// Create directory
|
||||||
|
socket_message_FSMkdirResponse handleMkdir(const socket_message_FSMkdirRequest& req);
|
||||||
|
|
||||||
|
// List directory
|
||||||
|
socket_message_FSListResponse handleList(const socket_message_FSListRequest& req);
|
||||||
|
|
||||||
|
// Download operations (ESP -> Client)
|
||||||
|
socket_message_FSDownloadStartResponse handleDownloadStart(const socket_message_FSDownloadStartRequest& req);
|
||||||
|
socket_message_FSDownloadChunkResponse handleDownloadChunk(const socket_message_FSDownloadChunkRequest& req);
|
||||||
|
|
||||||
|
// Upload operations (Client -> ESP)
|
||||||
|
socket_message_FSUploadStartResponse handleUploadStart(const socket_message_FSUploadStartRequest& req);
|
||||||
|
socket_message_FSUploadChunkResponse handleUploadChunk(const socket_message_FSUploadChunkRequest& req);
|
||||||
|
|
||||||
|
// Cancel transfer
|
||||||
|
socket_message_FSCancelTransferResponse handleCancelTransfer(const socket_message_FSCancelTransferRequest& req);
|
||||||
|
|
||||||
|
// Cleanup expired transfers
|
||||||
|
void cleanupExpiredTransfers();
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::map<std::string, TransferState> transfers_;
|
||||||
|
uint32_t transferIdCounter_;
|
||||||
|
|
||||||
|
std::string generateTransferId();
|
||||||
|
void listDirectory(const std::string& path, socket_message_FSListResponse& response);
|
||||||
|
bool deleteRecursive(const std::string& path);
|
||||||
|
};
|
||||||
|
|
||||||
|
extern FileSystemHandler fsHandler;
|
||||||
|
|
||||||
|
} // namespace FileSystemWS
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
#include <filesystem_ws.h>
|
||||||
|
#include <filesystem.h>
|
||||||
|
#include <pb_encode.h>
|
||||||
|
#include <pb_decode.h>
|
||||||
|
|
||||||
|
static const char* TAG = "FileSystemWS";
|
||||||
|
|
||||||
|
namespace FileSystemWS {
|
||||||
|
|
||||||
|
FileSystemHandler fsHandler;
|
||||||
|
|
||||||
|
FileSystemHandler::FileSystemHandler() : transferIdCounter_(0) {}
|
||||||
|
|
||||||
|
std::string FileSystemHandler::generateTransferId() {
|
||||||
|
return "xfer_" + std::to_string(millis()) + "_" + std::to_string(++transferIdCounter_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileSystemHandler::cleanupExpiredTransfers() {
|
||||||
|
uint32_t now = millis();
|
||||||
|
auto it = transfers_.begin();
|
||||||
|
while (it != transfers_.end()) {
|
||||||
|
if (now - it->second.lastActivityTime > FS_TRANSFER_TIMEOUT) {
|
||||||
|
if (it->second.file) {
|
||||||
|
it->second.file.close();
|
||||||
|
}
|
||||||
|
ESP_LOGW(TAG, "Transfer %s timed out", it->first.c_str());
|
||||||
|
it = transfers_.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket_message_FSDeleteResponse FileSystemHandler::handleDelete(const socket_message_FSDeleteRequest& req) {
|
||||||
|
socket_message_FSDeleteResponse response = socket_message_FSDeleteResponse_init_zero;
|
||||||
|
|
||||||
|
std::string path(req.path);
|
||||||
|
ESP_LOGI(TAG, "Delete request: %s", path.c_str());
|
||||||
|
|
||||||
|
if (!ESP_FS.exists(path.c_str())) {
|
||||||
|
response.success = false;
|
||||||
|
strncpy(response.error, "File or directory not found", sizeof(response.error) - 1);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteRecursive(path)) {
|
||||||
|
response.success = true;
|
||||||
|
} else {
|
||||||
|
response.success = false;
|
||||||
|
strncpy(response.error, "Failed to delete", sizeof(response.error) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileSystemHandler::deleteRecursive(const std::string& path) {
|
||||||
|
File file = ESP_FS.open(path.c_str());
|
||||||
|
if (!file) return false;
|
||||||
|
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
File child = file.openNextFile();
|
||||||
|
while (child) {
|
||||||
|
std::string childPath = std::string(child.name());
|
||||||
|
child.close();
|
||||||
|
if (!deleteRecursive(childPath)) {
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
child = file.openNextFile();
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
return ESP_FS.rmdir(path.c_str());
|
||||||
|
} else {
|
||||||
|
file.close();
|
||||||
|
return ESP_FS.remove(path.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket_message_FSMkdirResponse FileSystemHandler::handleMkdir(const socket_message_FSMkdirRequest& req) {
|
||||||
|
socket_message_FSMkdirResponse response = socket_message_FSMkdirResponse_init_zero;
|
||||||
|
|
||||||
|
std::string path(req.path);
|
||||||
|
ESP_LOGI(TAG, "Mkdir request: %s", path.c_str());
|
||||||
|
|
||||||
|
if (ESP_FS.exists(path.c_str())) {
|
||||||
|
response.success = false;
|
||||||
|
strncpy(response.error, "Path already exists", sizeof(response.error) - 1);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ESP_FS.mkdir(path.c_str())) {
|
||||||
|
response.success = true;
|
||||||
|
} else {
|
||||||
|
response.success = false;
|
||||||
|
strncpy(response.error, "Failed to create directory", sizeof(response.error) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FileSystemHandler::listDirectory(const std::string& path, socket_message_FSListResponse& response) {
|
||||||
|
File dir = ESP_FS.open(path.c_str());
|
||||||
|
if (!dir || !dir.isDirectory()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
File file = dir.openNextFile();
|
||||||
|
int fileCount = 0;
|
||||||
|
int dirCount = 0;
|
||||||
|
|
||||||
|
while (file && fileCount < 50 && dirCount < 50) { // Limit to prevent overflow
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
if (dirCount < 50) {
|
||||||
|
strncpy(response.directories[dirCount].name, file.name(), sizeof(response.directories[dirCount].name) - 1);
|
||||||
|
dirCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (fileCount < 50) {
|
||||||
|
strncpy(response.files[fileCount].name, file.name(), sizeof(response.files[fileCount].name) - 1);
|
||||||
|
response.files[fileCount].size = file.size();
|
||||||
|
fileCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file = dir.openNextFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
response.files_count = fileCount;
|
||||||
|
response.directories_count = dirCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket_message_FSListResponse FileSystemHandler::handleList(const socket_message_FSListRequest& req) {
|
||||||
|
socket_message_FSListResponse response = socket_message_FSListResponse_init_zero;
|
||||||
|
|
||||||
|
std::string path(req.path);
|
||||||
|
if (path.empty()) path = "/";
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "List request: %s", path.c_str());
|
||||||
|
|
||||||
|
if (!ESP_FS.exists(path.c_str())) {
|
||||||
|
response.success = false;
|
||||||
|
strncpy(response.error, "Path not found", sizeof(response.error) - 1);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
listDirectory(path, response);
|
||||||
|
response.success = true;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket_message_FSDownloadStartResponse FileSystemHandler::handleDownloadStart(const socket_message_FSDownloadStartRequest& req) {
|
||||||
|
socket_message_FSDownloadStartResponse response = socket_message_FSDownloadStartResponse_init_zero;
|
||||||
|
|
||||||
|
std::string path(req.path);
|
||||||
|
ESP_LOGI(TAG, "Download start request: %s", path.c_str());
|
||||||
|
|
||||||
|
if (!ESP_FS.exists(path.c_str())) {
|
||||||
|
response.success = false;
|
||||||
|
strncpy(response.error, "File not found", sizeof(response.error) - 1);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
File file = ESP_FS.open(path.c_str(), "r");
|
||||||
|
if (!file || file.isDirectory()) {
|
||||||
|
response.success = false;
|
||||||
|
strncpy(response.error, "Cannot open file for reading", sizeof(response.error) - 1);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t fileSize = file.size();
|
||||||
|
uint32_t chunkSize = FS_MAX_CHUNK_SIZE;
|
||||||
|
uint32_t totalChunks = (fileSize + chunkSize - 1) / chunkSize;
|
||||||
|
|
||||||
|
std::string transferId = generateTransferId();
|
||||||
|
|
||||||
|
TransferState state;
|
||||||
|
state.path = path;
|
||||||
|
state.file = file;
|
||||||
|
state.fileSize = fileSize;
|
||||||
|
state.chunkSize = chunkSize;
|
||||||
|
state.totalChunks = totalChunks;
|
||||||
|
state.chunksProcessed = 0;
|
||||||
|
state.lastActivityTime = millis();
|
||||||
|
state.isUpload = false;
|
||||||
|
|
||||||
|
transfers_[transferId] = state;
|
||||||
|
|
||||||
|
response.success = true;
|
||||||
|
response.file_size = fileSize;
|
||||||
|
response.chunk_size = chunkSize;
|
||||||
|
response.total_chunks = totalChunks;
|
||||||
|
strncpy(response.transfer_id, transferId.c_str(), sizeof(response.transfer_id) - 1);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Download started: %s, size=%u, chunks=%u, id=%s", path.c_str(), fileSize, totalChunks, transferId.c_str());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket_message_FSDownloadChunkResponse FileSystemHandler::handleDownloadChunk(const socket_message_FSDownloadChunkRequest& req) {
|
||||||
|
socket_message_FSDownloadChunkResponse response = socket_message_FSDownloadChunkResponse_init_zero;
|
||||||
|
|
||||||
|
std::string transferId(req.transfer_id);
|
||||||
|
strncpy(response.transfer_id, transferId.c_str(), sizeof(response.transfer_id) - 1);
|
||||||
|
response.chunk_index = req.chunk_index;
|
||||||
|
|
||||||
|
auto it = transfers_.find(transferId);
|
||||||
|
if (it == transfers_.end()) {
|
||||||
|
strncpy(response.error, "Invalid transfer ID", sizeof(response.error) - 1);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransferState& state = it->second;
|
||||||
|
state.lastActivityTime = millis();
|
||||||
|
|
||||||
|
// Seek to chunk position
|
||||||
|
uint32_t position = req.chunk_index * state.chunkSize;
|
||||||
|
if (!state.file.seek(position)) {
|
||||||
|
strncpy(response.error, "Failed to seek file", sizeof(response.error) - 1);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate chunk size (last chunk might be smaller)
|
||||||
|
uint32_t bytesToRead = state.chunkSize;
|
||||||
|
if (req.chunk_index == state.totalChunks - 1) {
|
||||||
|
bytesToRead = state.fileSize - position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read chunk data
|
||||||
|
size_t bytesRead = state.file.read(response.data.bytes, bytesToRead);
|
||||||
|
response.data.size = bytesRead;
|
||||||
|
|
||||||
|
response.is_last = (req.chunk_index == state.totalChunks - 1);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Download chunk %u/%u: %u bytes", req.chunk_index + 1, state.totalChunks, bytesRead);
|
||||||
|
|
||||||
|
// Cleanup if last chunk
|
||||||
|
if (response.is_last) {
|
||||||
|
state.file.close();
|
||||||
|
transfers_.erase(it);
|
||||||
|
ESP_LOGI(TAG, "Download completed: %s", transferId.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket_message_FSUploadStartResponse FileSystemHandler::handleUploadStart(const socket_message_FSUploadStartRequest& req) {
|
||||||
|
socket_message_FSUploadStartResponse response = socket_message_FSUploadStartResponse_init_zero;
|
||||||
|
|
||||||
|
std::string path(req.path);
|
||||||
|
ESP_LOGI(TAG, "Upload start request: %s, size=%u", path.c_str(), req.file_size);
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
size_t lastSlash = path.find_last_of('/');
|
||||||
|
if (lastSlash != std::string::npos && lastSlash > 0) {
|
||||||
|
std::string parentDir = path.substr(0, lastSlash);
|
||||||
|
if (!ESP_FS.exists(parentDir.c_str())) {
|
||||||
|
response.success = false;
|
||||||
|
strncpy(response.error, "Parent directory does not exist", sizeof(response.error) - 1);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File file = ESP_FS.open(path.c_str(), FILE_WRITE);
|
||||||
|
if (!file) {
|
||||||
|
response.success = false;
|
||||||
|
strncpy(response.error, "Cannot open file for writing", sizeof(response.error) - 1);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string transferId = generateTransferId();
|
||||||
|
|
||||||
|
TransferState state;
|
||||||
|
state.path = path;
|
||||||
|
state.file = file;
|
||||||
|
state.fileSize = req.file_size;
|
||||||
|
state.chunkSize = req.chunk_size > FS_MAX_CHUNK_SIZE ? FS_MAX_CHUNK_SIZE : req.chunk_size;
|
||||||
|
state.totalChunks = (req.file_size + state.chunkSize - 1) / state.chunkSize;
|
||||||
|
state.chunksProcessed = 0;
|
||||||
|
state.lastActivityTime = millis();
|
||||||
|
state.isUpload = true;
|
||||||
|
|
||||||
|
transfers_[transferId] = state;
|
||||||
|
|
||||||
|
response.success = true;
|
||||||
|
response.max_chunk_size = FS_MAX_CHUNK_SIZE;
|
||||||
|
strncpy(response.transfer_id, transferId.c_str(), sizeof(response.transfer_id) - 1);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Upload started: %s, id=%s", path.c_str(), transferId.c_str());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket_message_FSUploadChunkResponse FileSystemHandler::handleUploadChunk(const socket_message_FSUploadChunkRequest& req) {
|
||||||
|
socket_message_FSUploadChunkResponse response = socket_message_FSUploadChunkResponse_init_zero;
|
||||||
|
|
||||||
|
std::string transferId(req.transfer_id);
|
||||||
|
strncpy(response.transfer_id, transferId.c_str(), sizeof(response.transfer_id) - 1);
|
||||||
|
response.chunk_index = req.chunk_index;
|
||||||
|
|
||||||
|
auto it = transfers_.find(transferId);
|
||||||
|
if (it == transfers_.end()) {
|
||||||
|
response.success = false;
|
||||||
|
strncpy(response.error, "Invalid transfer ID", sizeof(response.error) - 1);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransferState& state = it->second;
|
||||||
|
state.lastActivityTime = millis();
|
||||||
|
|
||||||
|
// Write chunk data
|
||||||
|
size_t bytesWritten = state.file.write(req.data.bytes, req.data.size);
|
||||||
|
if (bytesWritten != req.data.size) {
|
||||||
|
response.success = false;
|
||||||
|
strncpy(response.error, "Failed to write chunk", sizeof(response.error) - 1);
|
||||||
|
state.file.close();
|
||||||
|
transfers_.erase(it);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.chunksProcessed++;
|
||||||
|
response.success = true;
|
||||||
|
response.transfer_complete = req.is_last;
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Upload chunk %u/%u: %u bytes", state.chunksProcessed, state.totalChunks, bytesWritten);
|
||||||
|
|
||||||
|
// Cleanup if last chunk
|
||||||
|
if (req.is_last) {
|
||||||
|
state.file.close();
|
||||||
|
transfers_.erase(it);
|
||||||
|
ESP_LOGI(TAG, "Upload completed: %s", state.path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket_message_FSCancelTransferResponse FileSystemHandler::handleCancelTransfer(const socket_message_FSCancelTransferRequest& req) {
|
||||||
|
socket_message_FSCancelTransferResponse response = socket_message_FSCancelTransferResponse_init_zero;
|
||||||
|
|
||||||
|
std::string transferId(req.transfer_id);
|
||||||
|
auto it = transfers_.find(transferId);
|
||||||
|
|
||||||
|
if (it == transfers_.end()) {
|
||||||
|
response.success = false;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (it->second.file) {
|
||||||
|
it->second.file.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete partial upload file
|
||||||
|
if (it->second.isUpload) {
|
||||||
|
ESP_FS.remove(it->second.path.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
transfers_.erase(it);
|
||||||
|
response.success = true;
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Transfer cancelled: %s", transferId.c_str());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace FileSystemWS
|
||||||
+112
-12
@@ -11,28 +11,112 @@ message KnownNetworkItem { string ssid = 1; string password = 2; bool static_ip
|
|||||||
|
|
||||||
// ----- FILESYSTEM -----
|
// ----- FILESYSTEM -----
|
||||||
|
|
||||||
message File { string name = 10; } // Add permissions?
|
message File {
|
||||||
|
string name = 10;
|
||||||
|
uint32 size = 20;
|
||||||
|
}
|
||||||
|
|
||||||
message Directory {
|
message Directory {
|
||||||
string name = 10;
|
string name = 10;
|
||||||
repeated File files = 20;
|
repeated File files = 20;
|
||||||
repeated Directory directories = 30;
|
repeated Directory directories = 30;
|
||||||
}
|
}
|
||||||
enum FileRequestOptions {
|
|
||||||
DELETE = 0;
|
|
||||||
EDIT = 1;
|
|
||||||
MKDIR = 2;
|
|
||||||
GET = 3;
|
|
||||||
POST = 4;
|
|
||||||
|
|
||||||
START_UPLOAD = 10;
|
// Delete a file or directory
|
||||||
|
message FSDeleteRequest {
|
||||||
|
string path = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message FSDelete { string path = 1; }
|
message FSDeleteResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message FilesystemRequest {
|
// Create directory
|
||||||
oneof type {
|
message FSMkdirRequest {
|
||||||
|
string path = 1;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
message FSMkdirResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List files/directories
|
||||||
|
message FSListRequest {
|
||||||
|
string path = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FSListResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string error = 2;
|
||||||
|
repeated File files = 3;
|
||||||
|
repeated Directory directories = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download from ESP (ESP -> Client)
|
||||||
|
message FSDownloadStartRequest {
|
||||||
|
string path = 1; // File path on ESP to download
|
||||||
|
}
|
||||||
|
|
||||||
|
message FSDownloadStartResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string error = 2;
|
||||||
|
uint32 file_size = 3; // Total file size in bytes
|
||||||
|
uint32 chunk_size = 4; // Size of each chunk (max 1024)
|
||||||
|
uint32 total_chunks = 5; // Total number of chunks
|
||||||
|
string transfer_id = 6; // Unique ID for this transfer
|
||||||
|
}
|
||||||
|
|
||||||
|
message FSDownloadChunkRequest {
|
||||||
|
string transfer_id = 1;
|
||||||
|
uint32 chunk_index = 2; // Which chunk to request (0-based)
|
||||||
|
}
|
||||||
|
|
||||||
|
message FSDownloadChunkResponse {
|
||||||
|
string transfer_id = 1;
|
||||||
|
uint32 chunk_index = 2;
|
||||||
|
bytes data = 3; // Chunk data (max 1024 bytes)
|
||||||
|
bool is_last = 4; // True if this is the last chunk
|
||||||
|
string error = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to ESP (Client -> ESP)
|
||||||
|
message FSUploadStartRequest {
|
||||||
|
string path = 1; // Destination path on ESP
|
||||||
|
uint32 file_size = 2; // Total file size in bytes
|
||||||
|
uint32 chunk_size = 3; // Size of each chunk (max 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
message FSUploadStartResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string error = 2;
|
||||||
|
string transfer_id = 3; // Unique ID for this transfer
|
||||||
|
uint32 max_chunk_size = 4; // Server's max chunk size (1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
message FSUploadChunkRequest {
|
||||||
|
string transfer_id = 1;
|
||||||
|
uint32 chunk_index = 2; // Which chunk this is (0-based)
|
||||||
|
bytes data = 3; // Chunk data (max 1024 bytes)
|
||||||
|
bool is_last = 4; // True if this is the last chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
message FSUploadChunkResponse {
|
||||||
|
string transfer_id = 1;
|
||||||
|
uint32 chunk_index = 2;
|
||||||
|
bool success = 3;
|
||||||
|
string error = 4;
|
||||||
|
bool transfer_complete = 5; // True when all chunks received
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel transfer
|
||||||
|
message FSCancelTransferRequest {
|
||||||
|
string transfer_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FSCancelTransferResponse {
|
||||||
|
bool success = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- FILESYSTEM -----
|
// ----- FILESYSTEM -----
|
||||||
@@ -83,6 +167,14 @@ message CorrelationRequest {
|
|||||||
I2CScanDataRequest i2c_scan_data_request = 20;
|
I2CScanDataRequest i2c_scan_data_request = 20;
|
||||||
IMUCalibrateExecute imu_calibrate_execute = 30;
|
IMUCalibrateExecute imu_calibrate_execute = 30;
|
||||||
SystemInformationRequest system_information_request = 40;
|
SystemInformationRequest system_information_request = 40;
|
||||||
|
FSDeleteRequest fs_delete_request = 50;
|
||||||
|
FSMkdirRequest fs_mkdir_request = 60;
|
||||||
|
FSListRequest fs_list_request = 70;
|
||||||
|
FSDownloadStartRequest fs_download_start_request = 80;
|
||||||
|
FSDownloadChunkRequest fs_download_chunk_request = 90;
|
||||||
|
FSUploadStartRequest fs_upload_start_request = 100;
|
||||||
|
FSUploadChunkRequest fs_upload_chunk_request = 110;
|
||||||
|
FSCancelTransferRequest fs_cancel_transfer_request = 120;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +186,14 @@ message CorrelationResponse {
|
|||||||
I2CScanData i2c_scan_data = 20;
|
I2CScanData i2c_scan_data = 20;
|
||||||
IMUCalibrateData imu_calibrate_data = 30;
|
IMUCalibrateData imu_calibrate_data = 30;
|
||||||
SystemInformationResponse system_information_response = 40;
|
SystemInformationResponse system_information_response = 40;
|
||||||
|
FSDeleteResponse fs_delete_response = 50;
|
||||||
|
FSMkdirResponse fs_mkdir_response = 60;
|
||||||
|
FSListResponse fs_list_response = 70;
|
||||||
|
FSDownloadStartResponse fs_download_start_response = 80;
|
||||||
|
FSDownloadChunkResponse fs_download_chunk_response = 90;
|
||||||
|
FSUploadStartResponse fs_upload_start_response = 100;
|
||||||
|
FSUploadChunkResponse fs_upload_chunk_response = 110;
|
||||||
|
FSCancelTransferResponse fs_cancel_transfer_response = 120;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user