diff --git a/VERSION b/VERSION
index 27f9cd3..a8fdfda 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.8.0
+1.8.1
diff --git a/changelog.json b/changelog.json
index a4ddf87..5fc5b67 100644
--- a/changelog.json
+++ b/changelog.json
@@ -1,5 +1,28 @@
{
"versions": [
+ {
+ "version": "1.8.1",
+ "date": "2026-05-12",
+ "changes": {
+ "features": [
+ "Upload offline (ZIP): même UX live que l'update online (progress bar dynamique, label étape, timer, logs techniques repliables)",
+ "Upload offline: self-heal via Step 4c qui appelle setup_services.sh (alignement complet avec l'online)"
+ ],
+ "improvements": [
+ "Frontend: mapping des étapes spécifique au mode (UPDATE_STEPS_ONLINE / UPDATE_STEPS_OFFLINE) selon le script lancé",
+ "Frontend: interpolation sub-step gère désormais Step 3c (online) et Step 4c (offline) de la même façon",
+ "Backend: route upload_firmware lance maintenant le script en background et réutilise le même mécanisme de log/done que l'update online",
+ "Détection de fin: substring 'completed successfully!' (matche les deux scripts qui ont des markers finaux légèrement différents)"
+ ],
+ "fixes": [
+ "Le bouton 'Upload & Install' restait bloqué sur 'Installing...' après succès — resetUpdateButton remet maintenant les deux boutons à zéro"
+ ],
+ "compatibility": [
+ "Aucun risque sur les capteurs existants: même logique, juste l'UX qui change côté UI"
+ ]
+ },
+ "notes": "Suite v1.8.0: alignement de l'upload offline sur le flow online. Les deux chemins partagent maintenant le même mécanisme de polling, la même progress bar, et le même self-heal Step 3c/4c. Plus de divergence entre les deux modes."
+ },
{
"version": "1.8.0",
"date": "2026-05-12",
diff --git a/html/admin.html b/html/admin.html
index 825db14..bc70e6c 100755
--- a/html/admin.html
+++ b/html/admin.html
@@ -853,8 +853,8 @@ function update_config(param, value){
}
// Mapping of step markers detected in the log -> progress %, step label, and weight
-// for sub-step interpolation. The script normally takes ~80-100s end-to-end.
-const UPDATE_STEPS = [
+// for sub-step interpolation. The online script normally takes ~80-100s end-to-end.
+const UPDATE_STEPS_ONLINE = [
{ marker: 'Step 1:', percent: 5, label: 'Téléchargement du firmware' },
{ marker: 'Step 2:', percent: 12, label: 'Mise à jour de la configuration BDD' },
{ marker: 'Step 3:', percent: 18, label: 'Vérification des permissions' },
@@ -862,9 +862,24 @@ const UPDATE_STEPS = [
{ marker: 'Step 4:', percent: 70, label: 'Redémarrage des services' },
{ marker: 'Step 5:', percent: 94, label: 'Vérification système' },
{ marker: 'Step 6:', percent: 98, label: 'Nettoyage des logs' },
- { marker: 'Update completed successfully!', percent: 100, label: '✅ Terminé !' }
+ { marker: 'completed successfully!', percent: 100, label: '✅ Terminé !' }
];
+// Offline upload uses a different shell script with different step numbering.
+const UPDATE_STEPS_OFFLINE = [
+ { marker: 'Step 1:', percent: 5, label: 'Validation du package' },
+ { marker: 'Step 2:', percent: 12, label: 'Synchronisation des fichiers' },
+ { marker: 'Step 3:', percent: 20, label: 'Mise à jour de la configuration BDD' },
+ { marker: 'Step 4:', percent: 25, label: 'Vérification des permissions' },
+ { marker: 'Step 4c:', percent: 30, label: 'Reconfiguration des services systemd' },
+ { marker: 'Step 5:', percent: 75, label: 'Redémarrage des services' },
+ { marker: 'Step 6:', percent: 94, label: 'Vérification système' },
+ { marker: 'Step 7:', percent: 98, label: 'Nettoyage des fichiers temporaires' },
+ { marker: 'completed successfully!', percent: 100, label: '✅ Terminé !' }
+];
+
+let UPDATE_STEPS = UPDATE_STEPS_ONLINE;
+
let updatePollState = null;
function updateFirmware() {
@@ -886,6 +901,7 @@ function updateFirmware() {
const reloadBtn = document.getElementById('reloadBtn');
// Reset UI
+ UPDATE_STEPS = UPDATE_STEPS_ONLINE;
updateBtn.disabled = true;
updateBtnText.textContent = 'Updating...';
updateSpinner.style.display = 'inline-block';
@@ -992,16 +1008,24 @@ function refreshProgressFromContent(allContent) {
let percent = bestStep.percent;
let label = bestStep.label;
- // Sub-step interpolation: within Step 3c (25 -> 70) and Step 4 (70 -> 94)
- // we count finer-grained markers to make progress feel smoother on slow steps.
- if (bestStep.marker === 'Step 3c:') {
+ // Sub-step interpolation: the longest steps benefit from finer-grained
+ // progress to make the bar feel alive on slow stages.
+ // - Online "Step 3c:" / Offline "Step 4c:" both run setup_services.sh
+ // - Online "Step 4:" / Offline "Step 5:" both restart services
+ if (bestStep.marker === 'Step 3c:' || bestStep.marker === 'Step 4c:') {
const startedCount = (allContent.match(/Started [\w-]+(?: timer| service)/g) || []).length;
- // Up to ~11 services are started by setup_services.sh -> step 3c spans 25-65
- percent = Math.min(65, 25 + startedCount * 4);
- } else if (bestStep.marker === 'Step 4:') {
+ // setup_services.sh starts ~11 services. Online: 25 -> 65. Offline: 30 -> 70.
+ const base = bestStep.percent;
+ const span = (bestStep.marker === 'Step 3c:') ? 40 : 40;
+ percent = Math.min(base + span, base + startedCount * 4);
+ } else if (
+ (UPDATE_STEPS === UPDATE_STEPS_ONLINE && bestStep.marker === 'Step 4:') ||
+ (UPDATE_STEPS === UPDATE_STEPS_OFFLINE && bestStep.marker === 'Step 5:')
+ ) {
const restartedCount = (allContent.match(/Restarting enabled service:/g) || []).length;
- // ~7 services restarted in step 4 -> spans 70-92
- percent = Math.min(92, 70 + restartedCount * 3);
+ // ~7 services restarted -> spans base -> base+21
+ const base = bestStep.percent;
+ percent = Math.min(base + 21, base + restartedCount * 3);
}
setProgress(percent, label);
@@ -1065,12 +1089,23 @@ function showUpdateError(message) {
}
function resetUpdateButton() {
+ // Online update button
const updateBtn = document.getElementById('updateBtn');
const updateBtnText = document.getElementById('updateBtnText');
const updateSpinner = document.getElementById('updateSpinner');
- updateBtn.disabled = false;
- updateBtnText.textContent = 'Update firmware';
- updateSpinner.style.display = 'none';
+ if (updateBtn) updateBtn.disabled = false;
+ if (updateBtnText) updateBtnText.textContent = 'Update firmware';
+ if (updateSpinner) updateSpinner.style.display = 'none';
+
+ // Offline upload button (in case the run was triggered via uploadFirmware)
+ const uploadBtn = document.getElementById('uploadBtn');
+ const uploadBtnText = document.getElementById('uploadBtnText');
+ const uploadSpinner = document.getElementById('uploadSpinner');
+ const uploadProgressBar = document.getElementById('uploadProgressBar');
+ if (uploadBtn) uploadBtn.disabled = false;
+ if (uploadBtnText) uploadBtnText.textContent = 'Upload & Install';
+ if (uploadSpinner) uploadSpinner.style.display = 'none';
+ if (uploadProgressBar) uploadProgressBar.style.display = 'none';
}
function uploadFirmware() {
@@ -1106,8 +1141,10 @@ function uploadFirmware() {
const progress = document.getElementById('uploadProgress');
const updateOutput = document.getElementById('updateOutput');
const updateOutputContent = document.getElementById('updateOutputContent');
+ const finalStatus = document.getElementById('updateFinalStatus');
+ const reloadBtn = document.getElementById('reloadBtn');
- // Show loading state
+ // Reset and show UI
uploadBtn.disabled = true;
uploadBtnText.textContent = 'Uploading...';
uploadSpinner.style.display = 'inline-block';
@@ -1115,15 +1152,18 @@ function uploadFirmware() {
progress.style.width = '0%';
progress.textContent = '0%';
updateOutput.style.display = 'block';
- updateOutputContent.textContent = 'Uploading firmware file...\n';
+ updateOutputContent.innerHTML = '';
+ finalStatus.style.display = 'none';
+ finalStatus.className = 'alert mb-3';
+ reloadBtn.style.display = 'none';
+ setProgress(0, 'Téléversement du fichier...');
// Build FormData
const formData = new FormData();
formData.append('firmware_file', file);
- // Use XMLHttpRequest for upload progress
const xhr = new XMLHttpRequest();
- xhr.timeout = 300000; // 5 minutes
+ xhr.timeout = 300000; // 5 minutes for the upload itself
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
@@ -1132,44 +1172,63 @@ function uploadFirmware() {
progress.textContent = pct + '%';
if (pct >= 100) {
uploadBtnText.textContent = 'Installing...';
- updateOutputContent.textContent = 'Upload complete. Installing firmware...\n';
}
}
});
xhr.addEventListener('load', function() {
+ let response;
try {
- const response = JSON.parse(xhr.responseText);
- if (response.success && response.output) {
- const formattedOutput = response.output
- .replace(/✓/g, '✓')
- .replace(/✗/g, '✗')
- .replace(/⚠/g, '⚠')
- .replace(/ℹ/g, 'ℹ');
- updateOutputContent.innerHTML = formattedOutput;
- showToast('Firmware updated: ' + (response.old_version || '?') + ' → ' + (response.new_version || '?'), 'success');
- document.getElementById('reloadBtn').style.display = 'inline-block';
- } else {
- updateOutputContent.textContent = 'Error: ' + (response.message || 'Unknown error');
- showToast('Upload failed: ' + (response.message || 'Unknown error'), 'error');
- }
+ response = JSON.parse(xhr.responseText);
} catch (e) {
- updateOutputContent.textContent = 'Error parsing response: ' + xhr.responseText;
- showToast('Update failed: invalid server response', 'error');
+ finalStatus.className = 'alert alert-danger mb-3';
+ finalStatus.textContent = 'Erreur: réponse serveur invalide';
+ finalStatus.style.display = 'block';
+ resetUploadUI();
+ showToast('Upload failed: invalid server response', 'error');
+ return;
}
- resetUploadUI();
+
+ if (!response.success) {
+ finalStatus.className = 'alert alert-danger mb-3';
+ finalStatus.textContent = 'Erreur: ' + (response.message || 'Erreur inconnue');
+ finalStatus.style.display = 'block';
+ resetUploadUI();
+ showToast('Upload failed: ' + (response.message || 'Unknown error'), 'error');
+ return;
+ }
+
+ // Upload + extraction OK → script is running in background, switch to polling
+ progressBar.style.display = 'none'; // hide upload bar, the update progress bar takes over
+ showToast('Upload OK, installation en cours...', 'info');
+
+ // Use offline step mapping
+ UPDATE_STEPS = UPDATE_STEPS_OFFLINE;
+
+ // Initial timer
+ const startTime = Date.now();
+ updateTimer(startTime);
+ const timerInterval = setInterval(() => updateTimer(startTime), 1000);
+
+ updatePollState = { offset: 0, allContent: '', startTime, timerInterval };
+ setProgress(2, 'Démarrage de l\'installation...');
+ pollUpdateProgress();
});
xhr.addEventListener('error', function() {
- updateOutputContent.textContent = 'Network error during upload';
- showToast('Upload failed: network error', 'error');
+ finalStatus.className = 'alert alert-danger mb-3';
+ finalStatus.textContent = 'Erreur réseau pendant l\'upload';
+ finalStatus.style.display = 'block';
resetUploadUI();
+ showToast('Upload failed: network error', 'error');
});
xhr.addEventListener('timeout', function() {
- updateOutputContent.textContent = 'Upload timed out (5 min limit)';
- showToast('Upload timed out', 'error');
+ finalStatus.className = 'alert alert-danger mb-3';
+ finalStatus.textContent = 'Upload interrompu (timeout 5 min)';
+ finalStatus.style.display = 'block';
resetUploadUI();
+ showToast('Upload timed out', 'error');
});
function resetUploadUI() {
diff --git a/html/launcher.php b/html/launcher.php
index 641421d..a782502 100755
--- a/html/launcher.php
+++ b/html/launcher.php
@@ -578,19 +578,31 @@ if ($type == "upload_firmware") {
$new_version = trim(file_get_contents("$source_dir/VERSION"));
- // Execute update script
- $command = "sudo /var/www/nebuleair_pro_4g/update_firmware_from_file.sh '$source_dir' 2>&1";
- $output = shell_exec($command);
+ // Launch update script in background, reusing the same log/done file mechanism
+ // as the online update. The frontend can then poll update_firmware_progress
+ // to get a live view and final status.
+ $logFile = '/tmp/nebuleair_firmware_update.log';
+ $doneFile = '/tmp/nebuleair_firmware_update.done';
- // Cleanup (also done in script, but just in case)
- shell_exec("rm -rf $tmp_dir");
+ @file_put_contents($logFile, '');
+ @unlink($doneFile);
+
+ // Note: $source_dir is escaped because it can contain a version-derived folder name.
+ // $tmp_dir is removed by the script itself (Step 7) but we also clean here as a safety net
+ // — done AFTER the script finishes, so we chain it inside the background block.
+ $cmd = '(sudo /var/www/nebuleair_pro_4g/update_firmware_from_file.sh '
+ . escapeshellarg($source_dir) . ' > '
+ . escapeshellarg($logFile) . ' 2>&1; '
+ . 'echo "EXIT_CODE=$?" >> ' . escapeshellarg($logFile) . '; '
+ . 'rm -rf ' . escapeshellarg($tmp_dir) . '; '
+ . 'touch ' . escapeshellarg($doneFile) . ') > /dev/null 2>&1 &';
+ shell_exec($cmd);
echo json_encode([
'success' => true,
- 'output' => $output,
+ 'started_at' => time(),
'old_version' => $old_version,
- 'new_version' => $new_version,
- 'timestamp' => date('Y-m-d H:i:s')
+ 'new_version' => $new_version
]);
exit;
}
diff --git a/update_firmware_from_file.sh b/update_firmware_from_file.sh
index a80bb2a..32bd08e 100644
--- a/update_firmware_from_file.sh
+++ b/update_firmware_from_file.sh
@@ -144,6 +144,21 @@ if [ "${APACHE_CHANGED:-false}" = true ]; then
print_status "✓ Apache reloaded"
fi
+# Step 4c: Reconcile systemd services with the repo (self-heal)
+# Same logic as update_firmware.sh: re-run setup_services.sh so any missing,
+# masked, or newly-added service in the canonical setup script is installed.
+# git config core.fileMode=false strips +x, so chmod first.
+print_status ""
+print_status "Step 4c: Reconciling systemd services with setup_services.sh..."
+SETUP_SERVICES="$TARGET_DIR/services/setup_services.sh"
+if [ -f "$SETUP_SERVICES" ]; then
+ chmod +x "$SETUP_SERVICES"
+ "$SETUP_SERVICES"
+ check_status "Setup services reconciliation"
+else
+ print_status "⚠ setup_services.sh not found, skipping"
+fi
+
# Step 5: Restart critical services
print_status ""
print_status "Step 5: Managing system services..."