diff --git a/index.js b/index.js
index 5ef6eaf..c14491e 100644
--- a/index.js
+++ b/index.js
@@ -1,5 +1,6 @@
const express = require('express');
const path = require('path');
+const fs = require('fs');
const SporeApiClient = require('./src/client');
const app = express();
@@ -77,6 +78,112 @@ app.get('/api/node/status/:ip', async (req, res) => {
}
});
+// File upload endpoint for firmware updates
+app.post('/api/node/update', express.raw({ type: 'multipart/form-data', limit: '50mb' }), async (req, res) => {
+ try {
+ const nodeIp = req.query.ip || req.headers['x-node-ip'];
+
+ if (!nodeIp) {
+ return res.status(400).json({
+ error: 'Node IP address is required',
+ message: 'Please provide the target node IP address'
+ });
+ }
+
+ // Parse multipart form data manually
+ const boundary = req.headers['content-type']?.split('boundary=')[1];
+ if (!boundary) {
+ return res.status(400).json({
+ error: 'Invalid content type',
+ message: 'Expected multipart/form-data with boundary'
+ });
+ }
+
+ // Parse the multipart data to extract the file
+ const fileData = parseMultipartData(req.body, boundary);
+
+ if (!fileData.file) {
+ return res.status(400).json({
+ error: 'No file data received',
+ message: 'Please select a firmware file to upload'
+ });
+ }
+
+ console.log(`Uploading firmware to node ${nodeIp}, file size: ${fileData.file.data.length} bytes, filename: ${fileData.file.filename}`);
+
+ // Create a temporary client for the specific node
+ const nodeClient = new SporeApiClient(`http://${nodeIp}`);
+
+ // Send the firmware data to the node using multipart form data
+ const updateResult = await nodeClient.updateFirmware(fileData.file.data, fileData.file.filename);
+
+ res.json({
+ success: true,
+ message: 'Firmware uploaded successfully',
+ nodeIp: nodeIp,
+ fileSize: fileData.file.data.length,
+ filename: fileData.file.filename,
+ result: updateResult
+ });
+
+ } catch (error) {
+ console.error('Error uploading firmware:', error);
+ res.status(500).json({
+ error: 'Failed to upload firmware',
+ message: error.message
+ });
+ }
+});
+
+// Helper function to parse multipart form data
+function parseMultipartData(buffer, boundary) {
+ const data = {};
+ const boundaryBuffer = Buffer.from(`--${boundary}`);
+ const endBoundaryBuffer = Buffer.from(`--${boundary}--`);
+
+ let start = 0;
+ let end = 0;
+
+ while (true) {
+ // Find the start of the next part
+ start = buffer.indexOf(boundaryBuffer, end);
+ if (start === -1) break;
+
+ // Find the end of this part
+ end = buffer.indexOf(boundaryBuffer, start + boundaryBuffer.length);
+ if (end === -1) {
+ end = buffer.indexOf(endBoundaryBuffer, start + boundaryBuffer.length);
+ if (end === -1) break;
+ }
+
+ // Extract the part content
+ const partBuffer = buffer.slice(start + boundaryBuffer.length + 2, end);
+
+ // Parse headers and content
+ const headerEnd = partBuffer.indexOf('\r\n\r\n');
+ if (headerEnd === -1) continue;
+
+ const headers = partBuffer.slice(0, headerEnd).toString();
+ const content = partBuffer.slice(headerEnd + 4);
+
+ // Parse the Content-Disposition header to get field name and filename
+ const nameMatch = headers.match(/name="([^"]+)"/);
+ const filenameMatch = headers.match(/filename="([^"]+)"/);
+
+ if (nameMatch) {
+ const fieldName = nameMatch[1];
+ const filename = filenameMatch ? filenameMatch[1] : null;
+
+ data[fieldName] = {
+ data: content,
+ filename: filename
+ };
+ }
+ }
+
+ return data;
+}
+
// Start the server
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
diff --git a/public/script.js b/public/script.js
index 23e1ef7..12a27b3 100644
--- a/public/script.js
+++ b/public/script.js
@@ -187,6 +187,7 @@ function displayNodeDetails(container, nodeStatus) {
📁 Choose Firmware File
Select a .bin or .hex file to upload
+
@@ -244,6 +245,18 @@ function setupTabs(container) {
fileInput.click();
}
});
+
+ // Set up file input change handler
+ const fileInput = container.querySelector('#firmware-file');
+ if (fileInput) {
+ fileInput.addEventListener('change', async (e) => {
+ e.stopPropagation();
+ const file = e.target.files[0];
+ if (file) {
+ await uploadFirmware(file, container);
+ }
+ });
+ }
}
}
@@ -297,6 +310,84 @@ async function loadTasksData(container, nodeStatus) {
}
}
+// Function to upload firmware
+async function uploadFirmware(file, container) {
+ const uploadStatus = container.querySelector('#upload-status');
+ const uploadBtn = container.querySelector('.upload-btn');
+ const originalText = uploadBtn.textContent;
+
+ try {
+ // Show upload status
+ uploadStatus.style.display = 'block';
+ uploadStatus.innerHTML = `
+
+
📤 Uploading ${file.name}...
+
Size: ${(file.size / 1024).toFixed(1)}KB
+
+ `;
+
+ // Disable upload button
+ uploadBtn.disabled = true;
+ uploadBtn.textContent = '⏳ Uploading...';
+
+ // Get the member IP from the card
+ const memberCard = container.closest('.member-card');
+ const memberIp = memberCard.dataset.memberIp;
+
+ if (!memberIp) {
+ throw new Error('Could not determine target node IP address');
+ }
+
+ // Create FormData for multipart upload
+ const formData = new FormData();
+ formData.append('file', file);
+
+ // Upload to backend
+ const response = await fetch('/api/node/update?ip=' + encodeURIComponent(memberIp), {
+ method: 'POST',
+ body: formData
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const result = await response.json();
+
+ // Show success
+ uploadStatus.innerHTML = `
+
+
✅ Firmware uploaded successfully!
+
Node: ${memberIp}
+
Size: ${(file.size / 1024).toFixed(1)}KB
+
+ `;
+
+ console.log('Firmware upload successful:', result);
+
+ } catch (error) {
+ console.error('Firmware upload failed:', error);
+
+ // Show error
+ uploadStatus.innerHTML = `
+
+
❌ Upload failed: ${error.message}
+
+ `;
+ } finally {
+ // Re-enable upload button
+ uploadBtn.disabled = false;
+ uploadBtn.textContent = originalText;
+
+ // Clear file input
+ const fileInput = container.querySelector('#firmware-file');
+ if (fileInput) {
+ fileInput.value = '';
+ }
+ }
+}
+
// Function to display cluster members
function displayClusterMembers(members, expandedCards = new Map()) {
const container = document.getElementById('cluster-members-container');
diff --git a/public/styles.css b/public/styles.css
index c204cb8..1429e95 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -422,6 +422,43 @@ p {
color: rgba(255, 255, 255, 0.8);
}
+/* Upload Status Styles */
+#upload-status {
+ margin-top: 1rem;
+ padding: 1rem;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.upload-progress {
+ text-align: center;
+ color: #ffa726;
+}
+
+.upload-success {
+ text-align: center;
+ color: #4caf50;
+ background: rgba(76, 175, 80, 0.1);
+ border: 1px solid rgba(76, 175, 80, 0.2);
+ padding: 0.75rem;
+ border-radius: 6px;
+}
+
+.upload-error {
+ text-align: center;
+ color: #f44336;
+ background: rgba(244, 67, 54, 0.1);
+ border: 1px solid rgba(244, 67, 54, 0.2);
+ padding: 0.75rem;
+ border-radius: 6px;
+}
+
+.upload-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none !important;
+}
+
.no-tasks {
text-align: center;
padding: 2rem;
diff --git a/src/client/index.js b/src/client/index.js
index 4c7acbe..cae5d9c 100644
--- a/src/client/index.js
+++ b/src/client/index.js
@@ -192,6 +192,22 @@ class SporeApiClient {
async factoryReset() {
return this.request('POST', '/api/device/factory-reset');
}
+
+ /**
+ * Update firmware on the device
+ * @param {Buffer|Uint8Array} firmwareData - Firmware binary data
+ * @returns {Promise