diff --git a/.update-exclude b/.update-exclude
new file mode 100644
index 0000000..79b5d43
--- /dev/null
+++ b/.update-exclude
@@ -0,0 +1,26 @@
+# NebuleAir Pro 4G - Fichiers exclus lors de la mise à jour par upload
+# Ce fichier est versionné dans le repo et voyage avec chaque release.
+# Quand on ajoute un nouveau capteur avec du cache local, mettre à jour cette liste.
+
+# Base de données (données capteur, config locale)
+sqlite/sensors.db
+sqlite/*.db-journal
+sqlite/*.db-wal
+
+# Logs applicatifs
+logs/
+
+# Historique git (pour que git pull fonctionne toujours après)
+.git/
+
+# Fichiers de configuration locale
+config.json
+deviceID.txt
+wifi_list.csv
+
+# Données capteurs en cache
+envea/data/
+NPM/data/
+
+# Verrous
+*.lock
diff --git a/VERSION b/VERSION
index 26aaba0..88c5fb8 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.2.0
+1.4.0
diff --git a/changelog.json b/changelog.json
index 55de0ee..8d70dc0 100644
--- a/changelog.json
+++ b/changelog.json
@@ -1,5 +1,25 @@
{
"versions": [
+ {
+ "version": "1.4.0",
+ "date": "2026-03-10",
+ "changes": {
+ "features": [
+ "Mise a jour firmware hors-ligne par upload de fichier ZIP via l'interface web admin",
+ "Barre de progression pour suivre l'upload du fichier",
+ "Fichier .update-exclude versionne pour gerer les exclusions rsync de maniere evolutive"
+ ],
+ "improvements": [
+ "Vidage du buffer serie avant chaque commande AT dans sara.py (evite les URCs residuelles au demarrage)"
+ ],
+ "fixes": [],
+ "compatibility": [
+ "Necessite l'ajout de update_firmware_from_file.sh dans les permissions sudo de www-data",
+ "Necessite Apache mod_rewrite pour html/.htaccess (upload 50MB)"
+ ]
+ },
+ "notes": "Permet la mise a jour du firmware sans connexion internet : telecharger le .zip depuis Gitea, se connecter au hotspot du capteur, et uploader via admin.html."
+ },
{
"version": "1.3.0",
"date": "2026-02-17",
diff --git a/html/.htaccess b/html/.htaccess
new file mode 100644
index 0000000..39d70cc
--- /dev/null
+++ b/html/.htaccess
@@ -0,0 +1,3 @@
+php_value upload_max_filesize 50M
+php_value post_max_size 55M
+php_value max_execution_time 300
diff --git a/html/admin.html b/html/admin.html
index 7a2d38d..892eed5 100755
--- a/html/admin.html
+++ b/html/admin.html
@@ -244,6 +244,20 @@
+
+ Mise à jour hors-ligne (upload)
+
+
+
+ Upload & Install
+
+
+
+
+ Télécharger le .zip depuis Gitea, puis le déposer ici
+
@@ -824,6 +838,116 @@ function updateFirmware() {
});
}
+function uploadFirmware() {
+ const fileInput = document.getElementById('firmwareFileInput');
+ const file = fileInput.files[0];
+
+ if (!file) {
+ showToast('Please select a .zip file first', 'warning');
+ return;
+ }
+
+ // Validate extension
+ if (!file.name.toLowerCase().endsWith('.zip')) {
+ showToast('Only .zip files are allowed', 'error');
+ return;
+ }
+
+ // Validate size (50MB)
+ if (file.size > 50 * 1024 * 1024) {
+ showToast('File too large (max 50MB)', 'error');
+ return;
+ }
+
+ if (!confirm('Install firmware from "' + file.name + '"?\nThis will update the system files and restart services.')) {
+ return;
+ }
+
+ // UI elements
+ const uploadBtn = document.getElementById('uploadBtn');
+ const uploadBtnText = document.getElementById('uploadBtnText');
+ const uploadSpinner = document.getElementById('uploadSpinner');
+ const progressBar = document.getElementById('uploadProgressBar');
+ const progress = document.getElementById('uploadProgress');
+ const updateOutput = document.getElementById('updateOutput');
+ const updateOutputContent = document.getElementById('updateOutputContent');
+
+ // Show loading state
+ uploadBtn.disabled = true;
+ uploadBtnText.textContent = 'Uploading...';
+ uploadSpinner.style.display = 'inline-block';
+ progressBar.style.display = 'flex';
+ progress.style.width = '0%';
+ progress.textContent = '0%';
+ updateOutput.style.display = 'block';
+ updateOutputContent.textContent = 'Uploading firmware file...\n';
+
+ // 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.upload.addEventListener('progress', function(e) {
+ if (e.lengthComputable) {
+ const pct = Math.round((e.loaded / e.total) * 100);
+ progress.style.width = pct + '%';
+ progress.textContent = pct + '%';
+ if (pct >= 100) {
+ uploadBtnText.textContent = 'Installing...';
+ updateOutputContent.textContent = 'Upload complete. Installing firmware...\n';
+ }
+ }
+ });
+
+ xhr.addEventListener('load', function() {
+ 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');
+ }
+ } catch (e) {
+ updateOutputContent.textContent = 'Error parsing response: ' + xhr.responseText;
+ showToast('Update failed: invalid server response', 'error');
+ }
+ resetUploadUI();
+ });
+
+ xhr.addEventListener('error', function() {
+ updateOutputContent.textContent = 'Network error during upload';
+ showToast('Upload failed: network error', 'error');
+ resetUploadUI();
+ });
+
+ xhr.addEventListener('timeout', function() {
+ updateOutputContent.textContent = 'Upload timed out (5 min limit)';
+ showToast('Upload timed out', 'error');
+ resetUploadUI();
+ });
+
+ function resetUploadUI() {
+ uploadBtn.disabled = false;
+ uploadBtnText.textContent = 'Upload & Install';
+ uploadSpinner.style.display = 'none';
+ progressBar.style.display = 'none';
+ }
+
+ xhr.open('POST', 'launcher.php?type=upload_firmware');
+ xhr.send(formData);
+}
+
function clearUpdateOutput() {
const updateOutput = document.getElementById('updateOutput');
const updateOutputContent = document.getElementById('updateOutputContent');
diff --git a/html/launcher.php b/html/launcher.php
index f942e40..53f23c7 100755
--- a/html/launcher.php
+++ b/html/launcher.php
@@ -410,6 +410,107 @@ if ($type == "update_firmware") {
]);
}
+if ($type == "upload_firmware") {
+ // Firmware update via ZIP file upload (offline mode)
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ echo json_encode(['success' => false, 'message' => 'POST method required']);
+ exit;
+ }
+
+ // Check file upload
+ if (!isset($_FILES['firmware_file']) || $_FILES['firmware_file']['error'] !== UPLOAD_ERR_OK) {
+ $upload_errors = [
+ UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit',
+ UPLOAD_ERR_FORM_SIZE => 'File exceeds form upload limit',
+ UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
+ UPLOAD_ERR_NO_FILE => 'No file was uploaded',
+ UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
+ UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
+ ];
+ $error_code = $_FILES['firmware_file']['error'] ?? UPLOAD_ERR_NO_FILE;
+ $error_msg = $upload_errors[$error_code] ?? 'Unknown upload error';
+ echo json_encode(['success' => false, 'message' => $error_msg]);
+ exit;
+ }
+
+ $file = $_FILES['firmware_file'];
+
+ // Validate extension
+ $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
+ if ($ext !== 'zip') {
+ echo json_encode(['success' => false, 'message' => 'Only .zip files are allowed']);
+ exit;
+ }
+
+ // Validate size (50MB max)
+ if ($file['size'] > 50 * 1024 * 1024) {
+ echo json_encode(['success' => false, 'message' => 'File too large (max 50MB)']);
+ exit;
+ }
+
+ // Get current version before update
+ $old_version = 'unknown';
+ if (file_exists('/var/www/nebuleair_pro_4g/VERSION')) {
+ $old_version = trim(file_get_contents('/var/www/nebuleair_pro_4g/VERSION'));
+ }
+
+ // Prepare extraction directory
+ $tmp_dir = '/tmp/nebuleair_update';
+ $extract_dir = "$tmp_dir/extracted";
+ shell_exec("rm -rf $tmp_dir");
+ mkdir($extract_dir, 0755, true);
+
+ // Move uploaded file
+ $zip_path = "$tmp_dir/firmware.zip";
+ if (!move_uploaded_file($file['tmp_name'], $zip_path)) {
+ echo json_encode(['success' => false, 'message' => 'Failed to move uploaded file']);
+ exit;
+ }
+
+ // Extract ZIP
+ $unzip_output = shell_exec("unzip -o '$zip_path' -d '$extract_dir' 2>&1");
+
+ // Detect project root folder (Gitea creates nebuleair_pro_4g-main/ inside the zip)
+ $source_dir = $extract_dir;
+ $entries = scandir($extract_dir);
+ $subdirs = array_filter($entries, function($e) use ($extract_dir) {
+ return $e !== '.' && $e !== '..' && is_dir("$extract_dir/$e");
+ });
+
+ if (count($subdirs) === 1) {
+ $subdir = reset($subdirs);
+ $candidate = "$extract_dir/$subdir";
+ if (file_exists("$candidate/VERSION")) {
+ $source_dir = $candidate;
+ }
+ }
+
+ // Validate VERSION exists in the archive
+ if (!file_exists("$source_dir/VERSION")) {
+ shell_exec("rm -rf $tmp_dir");
+ echo json_encode(['success' => false, 'message' => 'Invalid archive: VERSION file not found']);
+ exit;
+ }
+
+ $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);
+
+ // Cleanup (also done in script, but just in case)
+ shell_exec("rm -rf $tmp_dir");
+
+ echo json_encode([
+ 'success' => true,
+ 'output' => $output,
+ 'old_version' => $old_version,
+ 'new_version' => $new_version,
+ 'timestamp' => date('Y-m-d H:i:s')
+ ]);
+ exit;
+}
+
if ($type == "set_RTC_withNTP") {
$command = 'sudo /usr/bin/python3 /var/www/nebuleair_pro_4g/RTC/set_with_NTP.py';
$output = shell_exec($command);
diff --git a/update_firmware_from_file.sh b/update_firmware_from_file.sh
new file mode 100644
index 0000000..5e4bcfd
--- /dev/null
+++ b/update_firmware_from_file.sh
@@ -0,0 +1,185 @@
+#!/bin/bash
+
+# NebuleAir Pro 4G - Update from uploaded ZIP file
+# Usage: sudo ./update_firmware_from_file.sh /path/to/extracted/folder
+
+echo "======================================"
+echo "NebuleAir Pro 4G - Firmware Update (File Upload)"
+echo "======================================"
+echo "Started at: $(date)"
+echo ""
+
+# Set target directory
+TARGET_DIR="/var/www/nebuleair_pro_4g"
+EXCLUDE_FILE="$TARGET_DIR/.update-exclude"
+
+# Function to print status messages
+print_status() {
+ echo "[$(date '+%H:%M:%S')] $1"
+}
+
+# Function to check command success
+check_status() {
+ if [ $? -eq 0 ]; then
+ print_status "✓ $1 completed successfully"
+ else
+ print_status "✗ $1 failed"
+ return 1
+ fi
+}
+
+# Validate arguments
+if [ -z "$1" ]; then
+ print_status "✗ Error: No source directory provided"
+ print_status "Usage: $0 /path/to/extracted/folder"
+ exit 1
+fi
+
+SOURCE_DIR="$1"
+
+if [ ! -d "$SOURCE_DIR" ]; then
+ print_status "✗ Error: Source directory does not exist: $SOURCE_DIR"
+ exit 1
+fi
+
+# Step 1: Validate source and compare versions
+print_status "Step 1: Validating update package..."
+
+if [ ! -f "$SOURCE_DIR/VERSION" ]; then
+ print_status "✗ Error: VERSION file not found in update package"
+ exit 1
+fi
+
+NEW_VERSION=$(cat "$SOURCE_DIR/VERSION" | tr -d '[:space:]')
+OLD_VERSION="unknown"
+if [ -f "$TARGET_DIR/VERSION" ]; then
+ OLD_VERSION=$(cat "$TARGET_DIR/VERSION" | tr -d '[:space:]')
+fi
+
+print_status "Current version: $OLD_VERSION"
+print_status "New version: $NEW_VERSION"
+
+# Step 2: Rsync with exclusions from .update-exclude
+print_status ""
+print_status "Step 2: Syncing files..."
+
+# Build exclude args: use .update-exclude from the SOURCE (new version) if available,
+# otherwise fall back to the one already installed
+if [ -f "$SOURCE_DIR/.update-exclude" ]; then
+ EXCLUDE_FILE="$SOURCE_DIR/.update-exclude"
+ print_status "Using .update-exclude from update package"
+elif [ -f "$TARGET_DIR/.update-exclude" ]; then
+ EXCLUDE_FILE="$TARGET_DIR/.update-exclude"
+ print_status "Using .update-exclude from current installation"
+else
+ print_status "⚠ No .update-exclude file found, using built-in defaults"
+ # Fallback minimal exclusions
+ EXCLUDE_FILE=$(mktemp)
+ cat > "$EXCLUDE_FILE" <<'EXCL'
+sqlite/sensors.db
+sqlite/*.db-journal
+sqlite/*.db-wal
+logs/
+.git/
+config.json
+deviceID.txt
+wifi_list.csv
+envea/data/
+NPM/data/
+*.lock
+EXCL
+fi
+
+rsync -av --delete --exclude-from="$EXCLUDE_FILE" "$SOURCE_DIR/" "$TARGET_DIR/"
+check_status "File sync (rsync)"
+
+# Fix ownership and permissions
+print_status "Fixing ownership..."
+chown -R www-data:www-data "$TARGET_DIR/"
+check_status "Ownership fix (chown)"
+
+# Step 3: Update database configuration
+print_status ""
+print_status "Step 3: Updating database configuration..."
+/usr/bin/python3 "$TARGET_DIR/sqlite/set_config.py"
+check_status "Database configuration update"
+
+# Step 4: Check and fix file permissions
+print_status ""
+print_status "Step 4: Checking file permissions..."
+chmod +x "$TARGET_DIR/update_firmware.sh"
+chmod +x "$TARGET_DIR/update_firmware_from_file.sh"
+chmod 755 "$TARGET_DIR/sqlite/"*.py
+chmod 755 "$TARGET_DIR/NPM/"*.py
+chmod 755 "$TARGET_DIR/BME280/"*.py
+chmod 755 "$TARGET_DIR/SARA/"*.py
+chmod 755 "$TARGET_DIR/envea/"*.py
+chmod 755 "$TARGET_DIR/MPPT/"*.py 2>/dev/null
+chmod 755 "$TARGET_DIR/wifi/"*.py 2>/dev/null
+check_status "File permissions update"
+
+# Step 5: Restart critical services
+print_status ""
+print_status "Step 5: Managing system services..."
+
+services=(
+ "nebuleair-npm-data.timer"
+ "nebuleair-envea-data.timer"
+ "nebuleair-sara-data.timer"
+ "nebuleair-bme280-data.timer"
+ "nebuleair-mppt-data.timer"
+ "nebuleair-noise-data.timer"
+ "nebuleair-wifi-powersave.timer"
+)
+
+for service in "${services[@]}"; do
+ if systemctl list-unit-files | grep -q "$service"; then
+ if systemctl is-enabled --quiet "$service" 2>/dev/null; then
+ print_status "Restarting enabled service: $service"
+ systemctl restart "$service"
+ if systemctl is-active --quiet "$service"; then
+ print_status "✓ $service is running"
+ else
+ print_status "⚠ $service failed to start"
+ fi
+ else
+ print_status "ℹ Service $service is disabled, skipping restart"
+ fi
+ else
+ print_status "ℹ Service $service not found (may not be installed)"
+ fi
+done
+
+# Step 6: System health check
+print_status ""
+print_status "Step 6: System health check..."
+
+disk_usage=$(df / | awk 'NR==2 {print $5}' | sed 's/%//')
+if [ "$disk_usage" -gt 90 ]; then
+ print_status "⚠ Warning: Disk usage is high ($disk_usage%)"
+else
+ print_status "✓ Disk usage is acceptable ($disk_usage%)"
+fi
+
+if [ -f "$TARGET_DIR/sqlite/sensors.db" ]; then
+ print_status "✓ Database file exists"
+else
+ print_status "⚠ Warning: Database file not found"
+fi
+
+# Cleanup logs > 10MB
+find "$TARGET_DIR/logs" -name "*.log" -size +10M -exec truncate -s 0 {} \;
+check_status "Log cleanup"
+
+# Step 7: Cleanup temporary files
+print_status ""
+print_status "Step 7: Cleaning up temporary files..."
+rm -rf /tmp/nebuleair_update/
+check_status "Temp cleanup"
+
+print_status ""
+print_status "======================================"
+print_status "Update from $OLD_VERSION to $NEW_VERSION completed successfully!"
+print_status "======================================"
+
+exit 0