Add WiFi and HDMI power saving features for remote sensors

Implements power saving optimizations to extend battery life on solar-powered remote air quality sensors:

- WiFi Power Saving: Disable WiFi 10 minutes after boot to save ~100-200mA
  - Configurable via web UI checkbox in admin panel
  - WiFi automatically re-enables after reboot for 10-minute configuration window
  - Systemd timer (nebuleair-wifi-powersave.timer) manages automatic disable
  - New wifi/power_save.py script checks database config and disables WiFi via nmcli

- HDMI Disable: Added hdmi_blanking=2 to boot config to save ~20-30mA
  - Automatically configured during installation

- Database: Added wifi_power_saving boolean config (default: disabled)
  - Uses INSERT OR IGNORE for safe updates to existing installations

- UI: Added checkbox control in admin.html for WiFi power saving
  - Includes helpful description of power savings and behavior

- Services: Updated setup_services.sh and update_firmware.sh to manage new timer

Total power savings: ~120-230mA when WiFi power saving enabled

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
PaulVua
2026-01-13 12:05:21 +01:00
parent 13445d574c
commit 5777b35770
8 changed files with 217 additions and 14 deletions

View File

@@ -118,6 +118,16 @@
</label> </label>
</div> </div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" value="" id="check_wifi_power_saving" onchange="update_config_sqlite('wifi_power_saving', this.checked)">
<label class="form-check-label" for="check_wifi_power_saving">
WiFi Power Saving
</label>
<small class="form-text text-muted d-block ms-4">
Disable WiFi 10 minutes after boot to save power (~100-200mA). WiFi will re-enable after reboot.
</small>
</div>
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<span class="fw-bold">Protected Settings</span> <span class="fw-bold">Protected Settings</span>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="toggleProtectedSettings()" id="unlockBtn"> <button type="button" class="btn btn-sm btn-outline-primary" onclick="toggleProtectedSettings()" id="unlockBtn">
@@ -399,6 +409,7 @@ window.onload = function() {
const checkbox_envea = document.getElementById("check_envea"); const checkbox_envea = document.getElementById("check_envea");
const checkbox_solar = document.getElementById("check_solarBattery"); const checkbox_solar = document.getElementById("check_solarBattery");
const checkbox_noise = document.getElementById("check_NOISE"); const checkbox_noise = document.getElementById("check_NOISE");
const checkbox_wifi_power_saving = document.getElementById("check_wifi_power_saving");
checkbox_bme.checked = response["BME280"]; checkbox_bme.checked = response["BME280"];
checkbox_envea.checked = response["envea"]; checkbox_envea.checked = response["envea"];
@@ -406,6 +417,7 @@ window.onload = function() {
checkbox_nmp5channels.checked = response.npm_5channel; checkbox_nmp5channels.checked = response.npm_5channel;
checkbox_wind.checked = response["windMeter"]; checkbox_wind.checked = response["windMeter"];
checkbox_noise.checked = response["NOISE"]; checkbox_noise.checked = response["NOISE"];
checkbox_wifi_power_saving.checked = response["wifi_power_saving"];
checkbox_uSpot.checked = response["send_uSpot"]; checkbox_uSpot.checked = response["send_uSpot"];
checkbox_aircarto.checked = response["send_aircarto"]; checkbox_aircarto.checked = response["send_aircarto"];

View File

@@ -81,9 +81,9 @@ fi
info "Set config..." info "Set config..."
if [[ -f "$REPO_DIR/sqlite/set_config.py" ]]; then if [[ -f "$REPO_DIR/sqlite/set_config.py" ]]; then
sudo /usr/bin/python3 "$REPO_DIR/sqlite/set_config.py" || error "Failed to set config." sudo /usr/bin/python3 "$REPO_DIR/sqlite/set_config.py" || error "Failed to set config."
success "Databases created successfully." success "Databases set configuration successfully."
else else
warning "Database creation script not found." warning "Database set configuration script not found."
fi fi
# Configure Apache # Configure Apache
@@ -141,11 +141,11 @@ if ! sudo visudo -c; then
error "Sudoers file has syntax errors! Please fix manually with 'sudo visudo'" error "Sudoers file has syntax errors! Please fix manually with 'sudo visudo'"
fi fi
# Open all UART serial ports (avoid duplication) # Open all UART serial ports and disable HDMI (avoid duplication)
info "Configuring UART serial ports..." info "Configuring UART serial ports and disabling HDMI to save power..."
if ! grep -q "enable_uart=1" /boot/firmware/config.txt; then if ! grep -q "enable_uart=1" /boot/firmware/config.txt; then
echo -e "\nenable_uart=1\ndtoverlay=uart0\ndtoverlay=uart1\ndtoverlay=uart2\ndtoverlay=uart3\ndtoverlay=uart4\ndtoverlay=uart5" | sudo tee -a /boot/firmware/config.txt > /dev/null echo -e "\nenable_uart=1\ndtoverlay=uart0\ndtoverlay=uart1\ndtoverlay=uart2\ndtoverlay=uart3\ndtoverlay=uart4\ndtoverlay=uart5\n\n# Disable HDMI to save power (~20-30mA)\nhdmi_blanking=2" | sudo tee -a /boot/firmware/config.txt > /dev/null
success "UART configuration added." success "UART configuration and HDMI disable added."
else else
warning "UART configuration already set. Skipping." warning "UART configuration already set. Skipping."
fi fi
@@ -159,10 +159,6 @@ info "Enabling I2C ports..."
sudo raspi-config nonint do_i2c 0 sudo raspi-config nonint do_i2c 0
success "I2C ports enabled." success "I2C ports enabled."
#creates databases
info "Creates sqlites databases..."
/usr/bin/python3 /var/www/nebuleair_pro_4g/sqlite/create_db.py
# Final sudoers check # Final sudoers check
if sudo visudo -c; then if sudo visudo -c; then
success "Sudoers file is valid." success "Sudoers file is valid."

View File

@@ -0,0 +1,14 @@
[Unit]
Description=NebuleAir WiFi Power Save Service
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/wifi/power_save.py
User=root
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,12 @@
[Unit]
Description=NebuleAir WiFi Power Save Timer (10 minutes after boot)
After=network-online.target
[Timer]
# Run 10 minutes after system boot
OnBootSec=10min
# Don't persist timer across reboots
Persistent=false
[Install]
WantedBy=timers.target

View File

@@ -237,6 +237,39 @@ AccuracySec=1h
WantedBy=timers.target WantedBy=timers.target
EOL EOL
# Create service and timer files for WiFi Power Save
cat > /etc/systemd/system/nebuleair-wifi-powersave.service << 'EOL'
[Unit]
Description=NebuleAir WiFi Power Save Service
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /var/www/nebuleair_pro_4g/wifi/power_save.py
User=root
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOL
cat > /etc/systemd/system/nebuleair-wifi-powersave.timer << 'EOL'
[Unit]
Description=NebuleAir WiFi Power Save Timer (10 minutes after boot)
After=network-online.target
[Timer]
# Run 10 minutes after system boot
OnBootSec=10min
# Don't persist timer across reboots
Persistent=false
[Install]
WantedBy=timers.target
EOL
# Reload systemd to recognize new services # Reload systemd to recognize new services
systemctl daemon-reload systemctl daemon-reload
@@ -248,6 +281,11 @@ for service in npm envea sara bme280 mppt db-cleanup noise; do
echo "Started nebuleair-$service-data timer" echo "Started nebuleair-$service-data timer"
done done
# Enable and start WiFi power save timer (separate naming convention)
systemctl enable nebuleair-wifi-powersave.timer
systemctl start nebuleair-wifi-powersave.timer
echo "Started nebuleair-wifi-powersave timer"
echo "Checking status of all timers..." echo "Checking status of all timers..."
systemctl list-timers | grep nebuleair systemctl list-timers | grep nebuleair

View File

@@ -41,17 +41,18 @@ config_entries = [
("SARA_R4_network_status", "connected", "str"), ("SARA_R4_network_status", "connected", "str"),
("SARA_R4_neworkID", "20810", "int"), ("SARA_R4_neworkID", "20810", "int"),
("WIFI_status", "connected", "str"), ("WIFI_status", "connected", "str"),
("send_aircarto", "1", "bool"), ("send_aircarto", "0", "bool"),
("send_uSpot", "0", "bool"), ("send_uSpot", "0", "bool"),
("send_miotiq", "0", "bool"), ("send_miotiq", "1", "bool"),
("npm_5channel", "0", "bool"), ("npm_5channel", "0", "bool"),
("envea", "0", "bool"), ("envea", "0", "bool"),
("windMeter", "0", "bool"), ("windMeter", "0", "bool"),
("BME280", "0", "bool"), ("BME280", "1", "bool"),
("MPPT", "0", "bool"), ("MPPT", "0", "bool"),
("NOISE", "0", "bool"), ("NOISE", "0", "bool"),
("modem_version", "XXX", "str"), ("modem_version", "XXX", "str"),
("language", "fr", "str") ("language", "fr", "str"),
("wifi_power_saving", "0", "bool")
] ]
for key, value, value_type in config_entries: for key, value, value_type in config_entries:

View File

@@ -73,6 +73,7 @@ sudo chmod 755 /var/www/nebuleair_pro_4g/BME280/*.py
sudo chmod 755 /var/www/nebuleair_pro_4g/SARA/*.py sudo chmod 755 /var/www/nebuleair_pro_4g/SARA/*.py
sudo chmod 755 /var/www/nebuleair_pro_4g/envea/*.py sudo chmod 755 /var/www/nebuleair_pro_4g/envea/*.py
sudo chmod 755 /var/www/nebuleair_pro_4g/MPPT/*.py 2>/dev/null sudo chmod 755 /var/www/nebuleair_pro_4g/MPPT/*.py 2>/dev/null
sudo chmod 755 /var/www/nebuleair_pro_4g/wifi/*.py 2>/dev/null
check_status "File permissions update" check_status "File permissions update"
# Step 4: Restart critical services if they exist # Step 4: Restart critical services if they exist
@@ -87,6 +88,7 @@ services=(
"nebuleair-bme280-data.timer" "nebuleair-bme280-data.timer"
"nebuleair-mppt-data.timer" "nebuleair-mppt-data.timer"
"nebuleair-noise-data.timer" "nebuleair-noise-data.timer"
"nebuleair-wifi-powersave.timer"
) )
for service in "${services[@]}"; do for service in "${services[@]}"; do

128
wifi/power_save.py Executable file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
r'''
__ ___ ___ ___ ____
\ \ / (_) _(_) | | _ \ _____ _____ _ __
\ \ /\ / /| | | | | | | |_) / _ \ \ /\ / / _ \ '__|
\ V V / | | | | | | | __/ (_) \ V V / __/ |
\_/\_/ |_|_| |_|_| |_| \___/ \_/\_/ \___|_|
____
/ ___| __ ___ _____
\___ \ / _` \ \ / / _ \
___) | (_| |\ V / __/
|____/ \__,_| \_/ \___|
WiFi Power Saving Script
Disables WiFi after 10 minutes of boot if wifi_power_saving is enabled in config.
Saves ~100-200mA of power consumption.
Usage:
/usr/bin/python3 /var/www/nebuleair_pro_4g/wifi/power_save.py
'''
import sqlite3
import subprocess
import sys
import datetime
# Paths
DB_PATH = "/var/www/nebuleair_pro_4g/sqlite/sensors.db"
LOG_PATH = "/var/www/nebuleair_pro_4g/logs/app.log"
def log_message(message):
"""Write message to log file with timestamp"""
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] [WiFi Power Save] {message}\n"
try:
with open(LOG_PATH, "a") as log_file:
log_file.write(log_entry)
except Exception as e:
print(f"Failed to write to log: {e}", file=sys.stderr)
print(log_entry.strip())
def get_config_value(key):
"""Read configuration value from database"""
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT value FROM config_table WHERE key = ?", (key,))
result = cursor.fetchone()
conn.close()
return result[0] if result else None
except sqlite3.Error as e:
log_message(f"Database error: {e}")
return None
def is_wifi_enabled():
"""Check if WiFi radio is currently enabled"""
try:
result = subprocess.run(
["nmcli", "radio", "wifi"],
capture_output=True,
text=True,
timeout=10
)
return result.stdout.strip() == "enabled"
except Exception as e:
log_message(f"Failed to check WiFi status: {e}")
return None
def disable_wifi():
"""Disable WiFi radio using nmcli"""
try:
result = subprocess.run(
["sudo", "nmcli", "radio", "wifi", "off"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
log_message("WiFi disabled successfully - saving ~100-200mA power")
return True
else:
log_message(f"Failed to disable WiFi: {result.stderr}")
return False
except subprocess.TimeoutExpired:
log_message("Timeout while trying to disable WiFi")
return False
except Exception as e:
log_message(f"Error disabling WiFi: {e}")
return False
def main():
"""Main function"""
log_message("WiFi power save script started")
# Check if wifi_power_saving is enabled in config
wifi_power_saving = get_config_value("wifi_power_saving")
if wifi_power_saving is None:
log_message("wifi_power_saving config not found - skipping (run migration or set_config.py)")
return 0
if wifi_power_saving == "0":
log_message("WiFi power saving is disabled in config - WiFi will remain enabled")
return 0
log_message("WiFi power saving is enabled in config")
# Check current WiFi status
wifi_enabled = is_wifi_enabled()
if wifi_enabled is None:
log_message("Could not determine WiFi status - aborting")
return 1
if not wifi_enabled:
log_message("WiFi is already disabled - nothing to do")
return 0
# Disable WiFi
log_message("Disabling WiFi after 10-minute configuration window...")
if disable_wifi():
log_message("WiFi power save completed successfully")
return 0
else:
log_message("WiFi power save failed")
return 1
if __name__ == "__main__":
sys.exit(main())