feat: Implement NTP sync function with garbage collection and improve schedule handling in web server

reduce ram usage bascically.
Fixes #22 (more garbage collection all it needed, but went further to try and cut more memory usage)
Fixes #21 (Just forgot to already marke this one as completed. Possibly in this commit too)
This commit is contained in:
Aaron 2025-11-09 11:29:18 -05:00
parent 3c2e936d56
commit dae6971112
2 changed files with 120 additions and 91 deletions

View File

@ -636,6 +636,13 @@ class TempWebServer:
def _get_status_page(self, sensors, ac_monitor, heater_monitor, schedule_monitor=None, show_success=False): def _get_status_page(self, sensors, ac_monitor, heater_monitor, schedule_monitor=None, show_success=False):
"""Generate HTML status page.""" """Generate HTML status page."""
print("DEBUG: Generating status page...") print("DEBUG: Generating status page...")
# ===== FORCE GARBAGE COLLECTION BEFORE BIG ALLOCATION =====
import gc
gc.collect()
print("DEBUG: Memory freed, {} bytes available".format(gc.mem_free()))
# ===== END GARBAGE COLLECTION =====
try: try:
# Get current temperatures (use cached values to avoid blocking) # Get current temperatures (use cached values to avoid blocking)
inside_temp = getattr(sensors.get('inside'), 'last_temp', None) inside_temp = getattr(sensors.get('inside'), 'last_temp', None)
@ -1453,18 +1460,45 @@ class TempWebServer:
# Build mode buttons based on current state # Build mode buttons based on current state
if config.get('schedule_enabled'): if config.get('schedule_enabled'):
# ===== NEW: Find active schedule =====
active_schedule_name = "None"
current_time = time.localtime()
current_minutes = current_time[3] * 60 + current_time[4]
# Sort schedules by time and find the active one
sorted_schedules = []
for schedule in schedules:
if schedule.get('time'):
try:
time_parts = schedule['time'].split(':')
schedule_minutes = int(time_parts[0]) * 60 + int(time_parts[1])
sorted_schedules.append((schedule_minutes, schedule))
except:
pass
sorted_schedules.sort()
# Find most recent schedule that has passed
for schedule_minutes, schedule in sorted_schedules:
if current_minutes >= schedule_minutes:
active_schedule_name = schedule.get('name', 'Unnamed')
else:
break
# If no schedule found (before first one), use last from yesterday
if active_schedule_name == "None" and sorted_schedules:
active_schedule_name = sorted_schedules[-1][1].get('name', 'Unnamed')
# ===== END: Find active schedule =====
return """ return """
<form method="POST" action="/schedule" style="margin: 20px 0;"> <form method="POST" action="/schedule" style="margin: 20px 0;">
<div style="background: linear-gradient(135deg, #2ecc71, #27ae60); color: white; padding: 15px; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2);"> <div style="background: linear-gradient(135deg, #2ecc71, #27ae60); color: white; padding: 15px; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2);">
<div style="font-weight: bold; font-size: 18px; margin-bottom: 10px;"> Automatic Mode</div> <div style="font-weight: bold; font-size: 18px; margin-bottom: 5px;"> Automatic Mode</div>
<div style="font-size: 14px; margin-bottom: 15px;">Temperatures adjust based on schedule</div> <div style="font-size: 14px; opacity: 0.9; margin-bottom: 10px;">Currently running: <strong>{active_schedule}</strong></div>
<div style="display: flex; gap: 10px; justify-content: center;"> <div style="font-size: 13px; opacity: 0.8;">Temperatures adjust based on schedule</div>
<button type="submit" name="mode_action" value="temporary_hold" style="padding: 10px 20px; background: #f39c12; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;"> Temp Hold</button>
<button type="submit" name="mode_action" value="permanent_hold" style="padding: 10px 20px; background: #e74c3c; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">🛑 Perm Hold</button>
</div>
</div> </div>
</form> </form>
""" """.format(active_schedule=active_schedule_name)
elif config.get('permanent_hold', False): elif config.get('permanent_hold', False):
return """ return """
<form method="POST" action="/schedule" style="margin: 20px 0;"> <form method="POST" action="/schedule" style="margin: 20px 0;">
@ -1472,7 +1506,7 @@ class TempWebServer:
<div style="font-weight: bold; font-size: 18px; margin-bottom: 10px;">🛑 Permanent Hold</div> <div style="font-weight: bold; font-size: 18px; margin-bottom: 10px;">🛑 Permanent Hold</div>
<div style="font-size: 14px; margin-bottom: 15px;">Manual control only - Schedules disabled</div> <div style="font-size: 14px; margin-bottom: 15px;">Manual control only - Schedules disabled</div>
<div style="text-align: center;"> <div style="text-align: center;">
<button type="submit" name="mode_action" value="resume" style="padding: 10px 20px; background: #2ecc71; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;"> Enable Schedules</button> <button type="submit" name="mode_action" value="resume" style="padding: 10px 20px; background: #2ecc71; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;"> Resume Scheduling</button>
</div> </div>
</div> </div>
</form> </form>
@ -1483,9 +1517,8 @@ class TempWebServer:
<div style="background: linear-gradient(135deg, #f39c12, #e67e22); color: white; padding: 15px; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2);"> <div style="background: linear-gradient(135deg, #f39c12, #e67e22); color: white; padding: 15px; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2);">
<div style="font-weight: bold; font-size: 18px; margin-bottom: 10px;"> Temporary Hold</div> <div style="font-weight: bold; font-size: 18px; margin-bottom: 10px;"> Temporary Hold</div>
<div style="font-size: 14px; margin-bottom: 15px;">Manual override active</div> <div style="font-size: 14px; margin-bottom: 15px;">Manual override active</div>
<div style="display: flex; gap: 10px; justify-content: center;"> <div style="text-align: center;">
<button type="submit" name="mode_action" value="resume" style="padding: 10px 20px; background: #2ecc71; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;"> Resume</button> <button type="submit" name="mode_action" value="resume" style="padding: 10px 20px; background: #2ecc71; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;"> Resume Scheduling</button>
<button type="submit" name="mode_action" value="permanent_hold" style="padding: 10px 20px; background: #e74c3c; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">🛑 Perm Hold</button>
</div> </div>
</div> </div>
</form> </form>

156
main.py
View File

@ -4,9 +4,6 @@ import network # type: ignore
import json import json
import gc # type: ignore # ADD THIS - for garbage collection import gc # type: ignore # ADD THIS - for garbage collection
import sys import sys
import socket # type: ignore
import struct # type: ignore
import ntptime # type: ignore
# Initialize pins (LED light onboard) # Initialize pins (LED light onboard)
led = Pin("LED", Pin.OUT) led = Pin("LED", Pin.OUT)
@ -33,6 +30,53 @@ from scripts.web_server import TempWebServer
from scripts.scheduler import ScheduleMonitor # NEW: Import scheduler for time-based temp changes from scripts.scheduler import ScheduleMonitor # NEW: Import scheduler for time-based temp changes
from scripts.memory_check import check_memory_once # Just the function from scripts.memory_check import check_memory_once # Just the function
# ===== NEW: NTP Sync Function (imports locally) =====
def sync_ntp_time(timezone_offset):
"""
Sync time with NTP server (imports modules locally to save RAM).
Returns True if successful, False otherwise.
"""
try:
# Import ONLY when needed (freed by GC after function ends)
import socket # type: ignore
import struct # type: ignore
NTP_DELTA = 2208988800
host = "pool.ntp.org"
NTP_QUERY = bytearray(48)
NTP_QUERY[0] = 0x1B
# Create socket with timeout
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3.0) # 3-second timeout
try:
addr = socket.getaddrinfo(host, 123)[0][-1]
s.sendto(NTP_QUERY, addr)
msg = s.recv(48)
val = struct.unpack("!I", msg[40:44])[0]
utc_timestamp = val - NTP_DELTA
# Apply timezone offset
local_timestamp = utc_timestamp + (timezone_offset * 3600)
# Set RTC with local time
tm = time.gmtime(local_timestamp)
RTC().datetime((tm[0], tm[1], tm[2], tm[6] + 1, tm[3], tm[4], tm[5], 0))
return True
finally:
s.close()
except Exception as e:
print("NTP sync failed: {}".format(e))
return False
finally:
# Force garbage collection to free socket/struct modules
gc.collect()
# ===== END: NTP Sync Function =====
# ===== START: Configuration Loading ===== # ===== START: Configuration Loading =====
# Load saved settings from config.json file on Pico # Load saved settings from config.json file on Pico
def load_config(): def load_config():
@ -161,46 +205,17 @@ if wifi and wifi.isconnected():
web_server = TempWebServer(port=80) web_server = TempWebServer(port=80)
web_server.start() web_server.start()
# Attempt time sync with timeout (MicroPython compatible) # ===== INITIAL NTP SYNC (using function) =====
ntp_synced = False ntp_synced = False
try: try:
def time_with_timeout(): ntp_synced = sync_ntp_time(TIMEZONE_OFFSET)
"""NTP time fetch with 3-second timeout.""" if ntp_synced:
NTP_DELTA = 2208988800 print("Time synced with NTP server (UTC{:+d})".format(TIMEZONE_OFFSET))
host = "pool.ntp.org" else:
NTP_QUERY = bytearray(48) print("Initial NTP sync failed, will retry in background...")
NTP_QUERY[0] = 0x1B
# Create socket with timeout
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3.0) # 3-second timeout
try:
addr = socket.getaddrinfo(host, 123)[0][-1]
s.sendto(NTP_QUERY, addr)
msg = s.recv(48)
s.close()
val = struct.unpack("!I", msg[40:44])[0]
return val - NTP_DELTA
finally:
s.close()
# Get UTC time from NTP
utc_timestamp = time_with_timeout()
# Apply timezone offset
local_timestamp = utc_timestamp + (TIMEZONE_OFFSET * 3600)
# Set RTC with local time
tm = time.gmtime(local_timestamp)
RTC().datetime((tm[0], tm[1], tm[2], tm[6] + 1, tm[3], tm[4], tm[5], 0))
ntp_synced = True
print("Time synced with NTP server (UTC{:+d})".format(TIMEZONE_OFFSET))
except Exception as e: except Exception as e:
print("Initial NTP sync failed: {}".format(e)) print("Initial NTP sync error: {}".format(e))
print("Will retry in background...") # ===== END: INITIAL NTP SYNC =====
else: else:
# WiFi connection failed # WiFi connection failed
@ -349,6 +364,7 @@ print("Press Ctrl+C to stop\n")
# Add NTP retry flags (before main loop) # Add NTP retry flags (before main loop)
retry_ntp_attempts = 0 retry_ntp_attempts = 0
max_ntp_attempts = 5 # Try up to 5 times after initial failure max_ntp_attempts = 5 # Try up to 5 times after initial failure
last_ntp_sync = time.time() # Track when we last synced
# ===== START: Main Loop ===== # ===== START: Main Loop =====
# Main monitoring loop (runs forever until Ctrl+C) # Main monitoring loop (runs forever until Ctrl+C)
@ -360,46 +376,27 @@ while True:
# Web requests # Web requests
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config) web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor, config)
# Retry NTP sync every ~10s if not yet synced # ===== RETRY NTP SYNC (if initial failed) =====
if not ntp_synced and retry_ntp_attempts < max_ntp_attempts: if not ntp_synced and retry_ntp_attempts < max_ntp_attempts:
if retry_ntp_attempts == 0 or (time.time() % 10) < 1: if time.time() % 10 < 1: # Every 10 seconds
try: if sync_ntp_time(TIMEZONE_OFFSET):
import ntptime # type: ignore ntp_synced = True
import socket last_ntp_sync = time.time()
import struct print("NTP sync succeeded on retry #{} (UTC{:+d})".format(retry_ntp_attempts + 1, TIMEZONE_OFFSET))
else:
# Quick NTP sync with timeout
NTP_DELTA = 2208988800
host = "pool.ntp.org"
NTP_QUERY = bytearray(48)
NTP_QUERY[0] = 0x1B
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3.0) # 3-second timeout
try:
addr = socket.getaddrinfo(host, 123)[0][-1]
s.sendto(NTP_QUERY, addr)
msg = s.recv(48)
val = struct.unpack("!I", msg[40:44])[0]
utc_timestamp = val - NTP_DELTA
# Apply timezone offset
local_timestamp = utc_timestamp + (TIMEZONE_OFFSET * 3600)
# Set RTC with local time
tm = time.gmtime(local_timestamp)
RTC().datetime((tm[0], tm[1], tm[2], tm[6] + 1, tm[3], tm[4], tm[5], 0))
ntp_synced = True
print("NTP sync succeeded on retry #{} (UTC{:+d})".format(retry_ntp_attempts + 1, TIMEZONE_OFFSET))
finally:
s.close()
except Exception as e:
retry_ntp_attempts += 1 retry_ntp_attempts += 1
print("NTP retry {} failed: {}".format(retry_ntp_attempts, e)) print("NTP retry {} failed".format(retry_ntp_attempts))
# ===== PERIODIC RE-SYNC (every 24 hours) =====
# Re-sync once a day to correct clock drift
if ntp_synced and (time.time() - last_ntp_sync) > 86400: # 24 hours in seconds
print("24-hour re-sync due...")
if sync_ntp_time(TIMEZONE_OFFSET):
last_ntp_sync = time.time()
print("Daily NTP re-sync successful")
else:
print("Daily NTP re-sync failed (will retry tomorrow)")
# ===== END: PERIODIC RE-SYNC =====
# Enable garbage collection to free memory # Enable garbage collection to free memory
gc.collect() gc.collect()
time.sleep(0.1) time.sleep(0.1)
@ -424,6 +421,5 @@ while True:
print("❌ Main loop error: {}".format(e)) print("❌ Main loop error: {}".format(e))
import sys import sys
sys.print_exception(e) sys.print_exception(e)
print("⚠️ Pausing 5 seconds before retrying...")
time.sleep(5) # Brief pause before retrying time.sleep(5) # Brief pause before retrying
# ===== END: Main Loop ===== # ===== END: Main Loop =====