Compare commits
3 Commits
b8336f82c8
...
b93809946a
| Author | SHA1 | Date | |
|---|---|---|---|
| b93809946a | |||
| f4c9e20836 | |||
| 9fda192f0b |
@@ -55,6 +55,8 @@ class TemperatureMonitor(Monitor):
|
|||||||
self.last_report = 0
|
self.last_report = 0
|
||||||
self.alert_sent = False
|
self.alert_sent = False
|
||||||
self.alert_start_time = None # Track when alert started
|
self.alert_start_time = None # Track when alert started
|
||||||
|
self.last_temp = None # Cached Last temperature reading
|
||||||
|
self.last_read_time = 0 # Timestamp of last reading
|
||||||
|
|
||||||
def should_run(self):
|
def should_run(self):
|
||||||
"""Check if it's time to run this monitor."""
|
"""Check if it's time to run this monitor."""
|
||||||
@@ -75,6 +77,10 @@ class TemperatureMonitor(Monitor):
|
|||||||
|
|
||||||
temp = list(temps.values())[0] # Get first temp reading
|
temp = list(temps.values())[0] # Get first temp reading
|
||||||
|
|
||||||
|
# Cache the reading for web server (avoid blocking reads)
|
||||||
|
self.last_temp = temp
|
||||||
|
self.last_read_time = current_time
|
||||||
|
|
||||||
# Check for alerts
|
# Check for alerts
|
||||||
alert_condition = False
|
alert_condition = False
|
||||||
alert_message = ""
|
alert_message = ""
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class TempWebServer:
|
|||||||
self.port = port
|
self.port = port
|
||||||
self.socket = None
|
self.socket = None
|
||||||
self.sensors = {}
|
self.sensors = {}
|
||||||
|
self.last_page_render = 0 # Track last successful HTML generation
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start the web server (non-blocking)."""
|
"""Start the web server (non-blocking)."""
|
||||||
@@ -20,34 +21,47 @@ class TempWebServer:
|
|||||||
print("Web server started on port {}".format(self.port))
|
print("Web server started on port {}".format(self.port))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Failed to start web server: {}".format(e))
|
print("Failed to start web server: {}".format(e))
|
||||||
|
|
||||||
def check_requests(self, sensors, ac_monitor=None, heater_monitor=None, schedule_monitor=None):
|
def check_requests(self, sensors, ac_monitor=None, heater_monitor=None, schedule_monitor=None):
|
||||||
"""Check for incoming requests (call in main loop)."""
|
"""Check for incoming requests (call in main loop)."""
|
||||||
if not self.socket:
|
if not self.socket:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn, addr = self.socket.accept()
|
conn, addr = self.socket.accept()
|
||||||
conn.settimeout(3.0)
|
conn.settimeout(3.0)
|
||||||
request = conn.recv(1024).decode('utf-8')
|
request = conn.recv(1024).decode('utf-8')
|
||||||
|
|
||||||
# Check if this is a POST request (form submission)
|
|
||||||
if 'POST /update' in request:
|
if 'POST /update' in request:
|
||||||
response = self._handle_update(request, sensors, ac_monitor, heater_monitor, schedule_monitor)
|
response = self._handle_update(request, sensors, ac_monitor, heater_monitor, schedule_monitor)
|
||||||
|
|
||||||
|
elif 'GET /schedule' in request:
|
||||||
|
response = self._get_schedule_editor_page(sensors, ac_monitor, heater_monitor)
|
||||||
|
conn.send('HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n')
|
||||||
|
conn.sendall(response.encode('utf-8'))
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
elif 'POST /schedule' in request:
|
elif 'POST /schedule' in request:
|
||||||
response = self._handle_schedule_update(request, sensors, ac_monitor, heater_monitor, schedule_monitor)
|
response = self._handle_schedule_update(request, sensors, ac_monitor, heater_monitor, schedule_monitor)
|
||||||
|
# If handler returns a redirect response, send it raw and exit
|
||||||
|
if isinstance(response, str) and response.startswith('HTTP/1.1 303'):
|
||||||
|
conn.sendall(response.encode('utf-8'))
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
elif 'GET /ping' in request:
|
||||||
|
# Quick health check endpoint (no processing)
|
||||||
|
conn.send('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n')
|
||||||
|
conn.sendall(b'OK')
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Regular GET request
|
|
||||||
response = self._get_status_page(sensors, ac_monitor, heater_monitor)
|
response = self._get_status_page(sensors, ac_monitor, heater_monitor)
|
||||||
|
|
||||||
# Make sure we have a valid response
|
|
||||||
if response is None:
|
if response is None:
|
||||||
print("Error: response is None, generating default page")
|
|
||||||
response = self._get_status_page(sensors, ac_monitor, heater_monitor)
|
response = self._get_status_page(sensors, ac_monitor, heater_monitor)
|
||||||
|
|
||||||
conn.send('HTTP/1.1 200 OK\r\n')
|
conn.send('HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n')
|
||||||
conn.send('Content-Type: text/html; charset=utf-8\r\n')
|
|
||||||
conn.send('Connection: close\r\n\r\n')
|
|
||||||
conn.sendall(response.encode('utf-8'))
|
conn.sendall(response.encode('utf-8'))
|
||||||
conn.close()
|
conn.close()
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -56,12 +70,24 @@ class TempWebServer:
|
|||||||
print("Web server error: {}".format(e))
|
print("Web server error: {}".format(e))
|
||||||
import sys
|
import sys
|
||||||
sys.print_exception(e)
|
sys.print_exception(e)
|
||||||
|
|
||||||
def _save_config_to_file(self, config):
|
def _save_config_to_file(self, config):
|
||||||
"""Save configuration to config.json file."""
|
"""Save configuration to config.json file (atomic write)."""
|
||||||
try:
|
try:
|
||||||
with open('config.json', 'w') as f:
|
import os
|
||||||
|
# Write to temp file first
|
||||||
|
with open('config.tmp', 'w') as f:
|
||||||
json.dump(config, f)
|
json.dump(config, f)
|
||||||
|
|
||||||
|
# Remove old config if exists
|
||||||
|
try:
|
||||||
|
os.remove('config.json')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Rename temp to config (atomic on most filesystems)
|
||||||
|
os.rename('config.tmp', 'config.json')
|
||||||
|
|
||||||
print("Settings saved to config.json")
|
print("Settings saved to config.json")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -86,6 +112,7 @@ class TempWebServer:
|
|||||||
|
|
||||||
def _handle_schedule_update(self, request, sensors, ac_monitor, heater_monitor, schedule_monitor):
|
def _handle_schedule_update(self, request, sensors, ac_monitor, heater_monitor, schedule_monitor):
|
||||||
"""Handle schedule form submission."""
|
"""Handle schedule form submission."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
body = request.split('\r\n\r\n')[1] if '\r\n\r\n' in request else ''
|
body = request.split('\r\n\r\n')[1] if '\r\n\r\n' in request else ''
|
||||||
params = {}
|
params = {}
|
||||||
@@ -160,7 +187,6 @@ class TempWebServer:
|
|||||||
|
|
||||||
# Redirect back to homepage
|
# Redirect back to homepage
|
||||||
return 'HTTP/1.1 303 See Other\r\nLocation: /\r\n\r\n'
|
return 'HTTP/1.1 303 See Other\r\nLocation: /\r\n\r\n'
|
||||||
# ===== END: Handle mode actions =====
|
|
||||||
|
|
||||||
elif mode_action == 'save_schedules':
|
elif mode_action == 'save_schedules':
|
||||||
# Just fall through to schedule parsing below
|
# Just fall through to schedule parsing below
|
||||||
@@ -177,6 +203,20 @@ class TempWebServer:
|
|||||||
heater_key = 'schedule_{}_heater'.format(i)
|
heater_key = 'schedule_{}_heater'.format(i)
|
||||||
|
|
||||||
if time_key in params and params[time_key]:
|
if time_key in params and params[time_key]:
|
||||||
|
# Validate time format (HH:MM)
|
||||||
|
time_val = params[time_key]
|
||||||
|
if ':' not in time_val or len(time_val.split(':')) != 2:
|
||||||
|
print("Invalid time format: {}".format(time_val))
|
||||||
|
return 'HTTP/1.1 303 See Other\r\nLocation: /schedule\r\n\r\n'
|
||||||
|
|
||||||
|
try:
|
||||||
|
hours, mins = time_val.split(':')
|
||||||
|
if not (0 <= int(hours) <= 23 and 0 <= int(mins) <= 59):
|
||||||
|
raise ValueError
|
||||||
|
except:
|
||||||
|
print("Invalid time value: {}".format(time_val))
|
||||||
|
return 'HTTP/1.1 303 See Other\r\nLocation: /schedule\r\n\r\n'
|
||||||
|
|
||||||
schedule = {
|
schedule = {
|
||||||
'time': params[time_key],
|
'time': params[time_key],
|
||||||
'name': params.get(name_key, 'Schedule {}'.format(i+1)),
|
'name': params.get(name_key, 'Schedule {}'.format(i+1)),
|
||||||
@@ -223,13 +263,16 @@ class TempWebServer:
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
# ===== END: Handle schedule configuration save =====
|
# ===== END: Handle schedule configuration save =====
|
||||||
|
|
||||||
|
# Redirect back to schedule page
|
||||||
|
return 'HTTP/1.1 303 See Other\r\nLocation: /schedule\r\n\r\n'
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error updating schedule: {}".format(e))
|
print("Error updating schedule: {}".format(e))
|
||||||
import sys
|
import sys
|
||||||
sys.print_exception(e)
|
sys.print_exception(e)
|
||||||
|
# Safety: avoid rendering an error page here; just redirect
|
||||||
return self._get_status_page(sensors, ac_monitor, heater_monitor, show_success=True)
|
return 'HTTP/1.1 303 See Other\r\nLocation: /schedule\r\n\r\n'
|
||||||
|
|
||||||
def _handle_update(self, request, sensors, ac_monitor, heater_monitor, schedule_monitor):
|
def _handle_update(self, request, sensors, ac_monitor, heater_monitor, schedule_monitor):
|
||||||
"""Handle form submission and update settings."""
|
"""Handle form submission and update settings."""
|
||||||
@@ -342,12 +385,18 @@ class TempWebServer:
|
|||||||
"""Generate HTML status page."""
|
"""Generate HTML status page."""
|
||||||
print("DEBUG: Generating status page...")
|
print("DEBUG: Generating status page...")
|
||||||
try:
|
try:
|
||||||
# Get current temperatures
|
# Get current temperatures (use cached values to avoid blocking)
|
||||||
inside_temps = sensors['inside'].read_all_temps(unit='F')
|
inside_temp = getattr(sensors.get('inside'), 'last_temp', None)
|
||||||
outside_temps = sensors['outside'].read_all_temps(unit='F')
|
outside_temp = getattr(sensors.get('outside'), 'last_temp', None)
|
||||||
|
|
||||||
inside_temp = list(inside_temps.values())[0] if inside_temps else "N/A"
|
# Fallback to sensor read if no cached value (first load only)
|
||||||
outside_temp = list(outside_temps.values())[0] if outside_temps else "N/A"
|
if inside_temp is None:
|
||||||
|
inside_temps = sensors['inside'].read_all_temps(unit='F')
|
||||||
|
inside_temp = list(inside_temps.values())[0] if inside_temps else "N/A"
|
||||||
|
|
||||||
|
if outside_temp is None:
|
||||||
|
outside_temps = sensors['outside'].read_all_temps(unit='F')
|
||||||
|
outside_temp = list(outside_temps.values())[0] if outside_temps else "N/A"
|
||||||
|
|
||||||
# Get AC/Heater status
|
# Get AC/Heater status
|
||||||
ac_status = "ON" if ac_monitor and ac_monitor.ac.get_state() else "OFF"
|
ac_status = "ON" if ac_monitor and ac_monitor.ac.get_state() else "OFF"
|
||||||
@@ -386,6 +435,9 @@ class TempWebServer:
|
|||||||
|
|
||||||
# Build schedule cards
|
# Build schedule cards
|
||||||
schedule_cards = ""
|
schedule_cards = ""
|
||||||
|
|
||||||
|
# Build mode buttons for dashboard
|
||||||
|
mode_buttons = self._build_mode_buttons(config)
|
||||||
if config.get('schedules'):
|
if config.get('schedules'):
|
||||||
for schedule in config.get('schedules', []):
|
for schedule in config.get('schedules', []):
|
||||||
# ===== START: Decode URL-encoded values =====
|
# ===== START: Decode URL-encoded values =====
|
||||||
@@ -416,9 +468,6 @@ class TempWebServer:
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Build schedule form
|
|
||||||
schedule_form = self._build_schedule_form(config)
|
|
||||||
|
|
||||||
# Success message
|
# Success message
|
||||||
success_html = """
|
success_html = """
|
||||||
<div class="success-message">
|
<div class="success-message">
|
||||||
@@ -732,7 +781,13 @@ class TempWebServer:
|
|||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px;">
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px;">
|
||||||
{schedule_cards}
|
{schedule_cards}
|
||||||
</div>
|
</div>
|
||||||
{schedule_form}
|
{mode_buttons}
|
||||||
|
|
||||||
|
<div style="margin-top: 20px; text-align: center;">
|
||||||
|
<a href="/schedule" class="btn" style="text-decoration: none; display: inline-block;">
|
||||||
|
⚙️ Edit Schedules
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
@@ -759,8 +814,9 @@ class TempWebServer:
|
|||||||
schedule_color=schedule_color,
|
schedule_color=schedule_color,
|
||||||
schedule_icon=schedule_icon,
|
schedule_icon=schedule_icon,
|
||||||
schedule_cards=schedule_cards,
|
schedule_cards=schedule_cards,
|
||||||
schedule_form=schedule_form
|
mode_buttons=mode_buttons
|
||||||
)
|
)
|
||||||
|
self.last_page_render = time.time() # Track successful render
|
||||||
return html
|
return html
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -771,13 +827,11 @@ class TempWebServer:
|
|||||||
|
|
||||||
def _get_error_page(self, error_title, error_message, sensors, ac_monitor, heater_monitor):
|
def _get_error_page(self, error_title, error_message, sensors, ac_monitor, heater_monitor):
|
||||||
"""Generate error page with message."""
|
"""Generate error page with message."""
|
||||||
# Get current temps
|
# Get current temps (cached, fast - no blocking sensor reads)
|
||||||
inside_temps = sensors['inside'].read_all_temps(unit='F')
|
inside_temp = getattr(sensors.get('inside'), 'last_temp', None) or "N/A"
|
||||||
outside_temps = sensors['outside'].read_all_temps(unit='F')
|
outside_temp = getattr(sensors.get('outside'), 'last_temp', None) or "N/A"
|
||||||
|
|
||||||
inside_temp = list(inside_temps.values())[0] if inside_temps else "N/A"
|
|
||||||
outside_temp = list(outside_temps.values())[0] if outside_temps else "N/A"
|
|
||||||
|
|
||||||
|
# Format temperature values
|
||||||
inside_temp_str = "{:.1f}".format(inside_temp) if isinstance(inside_temp, float) else str(inside_temp)
|
inside_temp_str = "{:.1f}".format(inside_temp) if isinstance(inside_temp, float) else str(inside_temp)
|
||||||
outside_temp_str = "{:.1f}".format(outside_temp) if isinstance(outside_temp, float) else str(outside_temp)
|
outside_temp_str = "{:.1f}".format(outside_temp) if isinstance(outside_temp, float) else str(outside_temp)
|
||||||
|
|
||||||
@@ -896,137 +950,197 @@ class TempWebServer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return html
|
return html
|
||||||
|
|
||||||
def _build_schedule_form(self, config):
|
def _get_schedule_editor_page(self, sensors, ac_monitor, heater_monitor):
|
||||||
"""Build the schedule editing form."""
|
"""Generate schedule editor page (no auto-refresh, schedules only)."""
|
||||||
|
# Get current temps (use cached to avoid blocking)
|
||||||
|
inside_temp = getattr(sensors.get('inside'), 'last_temp', None) or "N/A"
|
||||||
|
outside_temp = getattr(sensors.get('outside'), 'last_temp', None) or "N/A"
|
||||||
|
|
||||||
|
# Format temperature values
|
||||||
|
inside_temp_str = "{:.1f}".format(inside_temp) if isinstance(inside_temp, float) else str(inside_temp)
|
||||||
|
outside_temp_str = "{:.1f}".format(outside_temp) if isinstance(outside_temp, float) else str(outside_temp)
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
config = self._load_config()
|
||||||
schedules = config.get('schedules', [])
|
schedules = config.get('schedules', [])
|
||||||
|
|
||||||
# Pad with empty schedules up to 4
|
# Pad with empty schedules up to 4
|
||||||
while len(schedules) < 4:
|
while len(schedules) < 4:
|
||||||
schedules.append({'time': '', 'name': '', 'ac_target': 77.0, 'heater_target': 80.0})
|
schedules.append({'time': '', 'name': '', 'ac_target': 75.0, 'heater_target': 72.0})
|
||||||
|
|
||||||
# ===== START: Determine current mode =====
|
|
||||||
# Check if schedules exist
|
|
||||||
has_schedules = len([s for s in schedules if s.get('time')]) > 0
|
|
||||||
|
|
||||||
# Determine mode based on config
|
|
||||||
if not has_schedules:
|
|
||||||
current_mode = "no_schedules" # No schedules configured yet
|
|
||||||
elif config.get('schedule_enabled'):
|
|
||||||
current_mode = "automatic" # Schedules are running
|
|
||||||
elif config.get('permanent_hold', False):
|
|
||||||
current_mode = "permanent_hold" # User disabled schedules permanently
|
|
||||||
else:
|
|
||||||
current_mode = "temporary_hold" # Manual override (HOLD mode)
|
|
||||||
# ===== END: Determine current mode =====
|
|
||||||
|
|
||||||
# ===== START: Build mode control buttons =====
|
|
||||||
if current_mode == "no_schedules":
|
|
||||||
# No mode buttons if no schedules configured
|
|
||||||
mode_buttons = """
|
|
||||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; color: #7f8c8d; margin-bottom: 20px;">
|
|
||||||
ℹ️ Configure schedules below, then choose a mode
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
elif current_mode == "automatic":
|
|
||||||
# Automatic mode active
|
|
||||||
mode_buttons = """
|
|
||||||
<div style="background: linear-gradient(135deg, #2ecc71, #27ae60); color: white; padding: 15px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 4px 8px rgba(0,0,0,0.2);">
|
|
||||||
<div style="font-weight: bold; font-size: 18px; margin-bottom: 10px;">
|
|
||||||
✅ Automatic Mode Active
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 14px; margin-bottom: 15px;">
|
|
||||||
Temperatures automatically adjust based on schedule
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: 10px; justify-content: center;">
|
|
||||||
<button type="submit" name="mode_action" value="temporary_hold" style="padding: 8px 16px; background: #f39c12; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">
|
|
||||||
⏸️ Temporary Hold
|
|
||||||
</button>
|
|
||||||
<button type="submit" name="mode_action" value="permanent_hold" style="padding: 8px 16px; background: #e74c3c; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">
|
|
||||||
🛑 Permanent Hold
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
elif current_mode == "temporary_hold":
|
|
||||||
# Temporary hold (manual override)
|
|
||||||
mode_buttons = """
|
|
||||||
<div style="background: linear-gradient(135deg, #f39c12, #e67e22); color: white; padding: 15px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 4px 8px rgba(0,0,0,0.2);">
|
|
||||||
<div style="font-weight: bold; font-size: 18px; margin-bottom: 10px;">
|
|
||||||
⏸️ Temporary Hold Active
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 14px; margin-bottom: 15px;">
|
|
||||||
Manual settings in use - Schedule paused
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: 10px; justify-content: center;">
|
|
||||||
<button type="submit" name="mode_action" value="resume" style="padding: 8px 16px; background: #2ecc71; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">
|
|
||||||
▶️ Resume Schedule
|
|
||||||
</button>
|
|
||||||
<button type="submit" name="mode_action" value="permanent_hold" style="padding: 8px 16px; background: #e74c3c; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">
|
|
||||||
🛑 Make Permanent
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
else: # permanent_hold
|
|
||||||
# Permanent hold (schedules disabled by user)
|
|
||||||
mode_buttons = """
|
|
||||||
<div style="background: linear-gradient(135deg, #e74c3c, #c0392b); color: white; padding: 15px; border-radius: 10px; margin-bottom: 20px; box-shadow: 0 4px 8px rgba(0,0,0,0.2);">
|
|
||||||
<div style="font-weight: bold; font-size: 18px; margin-bottom: 10px;">
|
|
||||||
🛑 Permanent Hold Active
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 14px; margin-bottom: 15px;">
|
|
||||||
Schedules disabled - Manual control only
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: 10px; justify-content: center;">
|
|
||||||
<button type="submit" name="mode_action" value="resume" style="padding: 8px 16px; background: #2ecc71; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">
|
|
||||||
▶️ Enable Schedules
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
# ===== END: Build mode control buttons =====
|
|
||||||
|
|
||||||
form = """
|
|
||||||
<form method="POST" action="/schedule" class="controls" style="margin-top: 20px;">
|
|
||||||
<h3 style="color: #34495e; margin-bottom: 15px;">⚙️ Schedule Configuration</h3>
|
|
||||||
|
|
||||||
{mode_buttons}
|
|
||||||
""".format(mode_buttons=mode_buttons)
|
|
||||||
|
|
||||||
|
# Build schedule inputs
|
||||||
|
schedule_inputs = ""
|
||||||
for i, schedule in enumerate(schedules[:4]):
|
for i, schedule in enumerate(schedules[:4]):
|
||||||
form += """
|
schedule_inputs += """
|
||||||
<div class="schedule-row">
|
<div style="display: grid; grid-template-columns: 1fr 2fr 1fr 1fr; gap: 10px; margin-bottom: 10px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
|
||||||
<div class="control-group" style="margin: 0;">
|
<div>
|
||||||
<label class="control-label" style="font-size: 14px;">Time</label>
|
<label style="font-size: 14px; font-weight: bold; color: #34495e; display: block; margin-bottom: 5px;">Time</label>
|
||||||
<input type="time" name="schedule_{i}_time" value="{time}">
|
<input type="time" name="schedule_{i}_time" value="{time}" style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 5px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group" style="margin: 0;">
|
<div>
|
||||||
<label class="control-label" style="font-size: 14px;">Name</label>
|
<label style="font-size: 14px; font-weight: bold; color: #34495e; display: block; margin-bottom: 5px;">Name</label>
|
||||||
<input type="text" name="schedule_{i}_name" value="{name}" placeholder="e.g. Morning">
|
<input type="text" name="schedule_{i}_name" value="{name}" placeholder="e.g. Morning" style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 5px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group" style="margin: 0;">
|
<div>
|
||||||
<label class="control-label" style="font-size: 14px;">Heater (°F)</label>
|
<label style="font-size: 14px; font-weight: bold; color: #34495e; display: block; margin-bottom: 5px;">🔥 Heater (°F)</label>
|
||||||
<input type="number" name="schedule_{i}_heater" value="{heater}" step="0.5" min="60" max="85">
|
<input type="number" name="schedule_{i}_heater" value="{heater}" step="0.5" min="60" max="85" style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 5px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group" style="margin: 0;">
|
<div>
|
||||||
<label class="control-label" style="font-size: 14px;">AC (°F)</label>
|
<label style="font-size: 14px; font-weight: bold; color: #34495e; display: block; margin-bottom: 5px;">❄️ AC (°F)</label>
|
||||||
<input type="number" name="schedule_{i}_ac" value="{ac}" step="0.5" min="60" max="85">
|
<input type="number" name="schedule_{i}_ac" value="{ac}" step="0.5" min="60" max="85" style="width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 5px;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
""".format(
|
""".format(
|
||||||
i=i,
|
i=i,
|
||||||
time=schedule.get('time', ''),
|
time=schedule.get('time', ''),
|
||||||
name=schedule.get('name', ''),
|
name=schedule.get('name', ''),
|
||||||
ac=schedule.get('ac_target', 77.0),
|
heater=schedule.get('heater_target', 72.0),
|
||||||
heater=schedule.get('heater_target', 80.0)
|
ac=schedule.get('ac_target', 75.0)
|
||||||
)
|
)
|
||||||
|
|
||||||
form += """
|
html = """
|
||||||
<div class="control-group" style="margin-top: 20px;">
|
<!DOCTYPE html>
|
||||||
<button type="submit" name="mode_action" value="save_schedules" class="btn">💾 Save Schedule Changes</button>
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Schedule Editor - Climate Control</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}}
|
||||||
|
.container {{
|
||||||
|
background: white;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
color: #2c3e50;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}}
|
||||||
|
.header-info {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
}}
|
||||||
|
.btn {{
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}}
|
||||||
|
.btn:hover {{ transform: translateY(-2px); }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>📅 Schedule Configuration</h1>
|
||||||
|
|
||||||
|
<div class="header-info">
|
||||||
|
<div>🏠 Inside: <strong>{inside_temp}°F</strong></div>
|
||||||
|
<div>🌡️ Outside: <strong>{outside_temp}°F</strong></div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
"""
|
<form method="POST" action="/schedule">
|
||||||
|
<h3 style="color: #34495e; margin-bottom: 15px;">⏰ Configure Schedule Times & Temperatures</h3>
|
||||||
|
<p style="color: #7f8c8d; margin-bottom: 20px;">
|
||||||
|
Set up to 4 time-based schedules. Leave time blank to disable a schedule.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{schedule_inputs}
|
||||||
|
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<button type="submit" name="mode_action" value="save_schedules" class="btn" style="width: 100%;">
|
||||||
|
💾 Save Schedule Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 20px;">
|
||||||
|
<a href="/" class="btn" style="background: linear-gradient(135deg, #95a5a6, #7f8c8d);">
|
||||||
|
⬅️ Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; color: #7f8c8d; margin-top: 20px; padding-top: 20px; border-top: 2px solid #ecf0f1;">
|
||||||
|
💡 This page does not auto-refresh<br>
|
||||||
|
To change modes (Automatic/Hold), return to the dashboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".format(
|
||||||
|
inside_temp=inside_temp_str,
|
||||||
|
outside_temp=outside_temp_str,
|
||||||
|
schedule_inputs=schedule_inputs
|
||||||
|
)
|
||||||
|
|
||||||
return form
|
return html
|
||||||
|
|
||||||
|
def _build_mode_buttons(self, config):
|
||||||
|
"""Build mode control buttons for dashboard only."""
|
||||||
|
schedules = config.get('schedules', [])
|
||||||
|
has_schedules = len([s for s in schedules if s.get('time')]) > 0
|
||||||
|
|
||||||
|
if not has_schedules:
|
||||||
|
return """
|
||||||
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; color: #7f8c8d; margin: 20px 0;">
|
||||||
|
ℹ️ No schedules configured - <a href="/schedule" style="color: #667eea; font-weight: bold;">Configure schedules</a>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Build mode buttons based on current state
|
||||||
|
if config.get('schedule_enabled'):
|
||||||
|
return """
|
||||||
|
<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="font-weight: bold; font-size: 18px; margin-bottom: 10px;">✅ Automatic Mode</div>
|
||||||
|
<div style="font-size: 14px; margin-bottom: 15px;">Temperatures adjust based on schedule</div>
|
||||||
|
<div style="display: flex; gap: 10px; justify-content: center;">
|
||||||
|
<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;">⏸️ Pause</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;">🛑 Disable</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
elif config.get('permanent_hold', False):
|
||||||
|
return """
|
||||||
|
<form method="POST" action="/schedule" style="margin: 20px 0;">
|
||||||
|
<div style="background: linear-gradient(135deg, #e74c3c, #c0392b); 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;">🛑 Permanent Hold</div>
|
||||||
|
<div style="font-size: 14px; margin-bottom: 15px;">Manual control only - Schedules disabled</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
return """
|
||||||
|
<form method="POST" action="/schedule" style="margin: 20px 0;">
|
||||||
|
<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-size: 14px; margin-bottom: 15px;">Manual override active</div>
|
||||||
|
<div style="display: flex; gap: 10px; justify-content: 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="permanent_hold" style="padding: 10px 20px; background: #e74c3c; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: bold;">🛑 Disable</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
61
main.py
61
main.py
@@ -1,9 +1,13 @@
|
|||||||
from machine import Pin
|
from machine import Pin, WDT
|
||||||
import time
|
import time
|
||||||
import network
|
import network
|
||||||
import json
|
import json
|
||||||
import gc # ADD THIS - for garbage collection
|
import gc # ADD THIS - for garbage collection
|
||||||
|
|
||||||
|
# Enable watchdog (8 seconds timeout - auto-reboot if frozen)
|
||||||
|
wdt = WDT(timeout=60000)
|
||||||
|
print("Watchdog enabled (60s timeout)")
|
||||||
|
|
||||||
# Initialize pins (LED light onboard)
|
# Initialize pins (LED light onboard)
|
||||||
led = Pin("LED", Pin.OUT)
|
led = Pin("LED", Pin.OUT)
|
||||||
led.low()
|
led.low()
|
||||||
@@ -124,16 +128,20 @@ if wifi and wifi.isconnected():
|
|||||||
# Send startup notification to Discord
|
# Send startup notification to Discord
|
||||||
send_discord_message(f"Pico W online at http://{ifconfig[0]} ✅")
|
send_discord_message(f"Pico W online at http://{ifconfig[0]} ✅")
|
||||||
|
|
||||||
# ===== START: NTP Time Sync =====
|
# Start web server early so page can load even if time sync is slow
|
||||||
# Sync time with internet time server (required for schedules to work correctly)
|
web_server = TempWebServer(port=80)
|
||||||
# Without this, the Pico's clock starts at 2021 on every reboot
|
web_server.start()
|
||||||
|
|
||||||
|
# Attempt time sync non-blocking (short timeout + retry flag)
|
||||||
|
ntp_synced = False
|
||||||
try:
|
try:
|
||||||
import ntptime
|
import ntptime
|
||||||
ntptime.settime() # Downloads current time from pool.ntp.org
|
ntptime.settime()
|
||||||
|
ntp_synced = True
|
||||||
print("Time synced with NTP server")
|
print("Time synced with NTP server")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Failed to sync time: {}".format(e))
|
print("Initial NTP sync failed: {}".format(e))
|
||||||
# ===== END: NTP Time Sync =====
|
# Will retry later in loop
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# WiFi connection failed
|
# WiFi connection failed
|
||||||
@@ -142,12 +150,6 @@ else:
|
|||||||
print("="*50 + "\n")
|
print("="*50 + "\n")
|
||||||
# ===== END: WiFi Connection =====
|
# ===== END: WiFi Connection =====
|
||||||
|
|
||||||
# ===== START: Web Server Setup =====
|
|
||||||
# Start web server for monitoring and control (accessible at http://192.168.86.43)
|
|
||||||
web_server = TempWebServer(port=80)
|
|
||||||
web_server.start()
|
|
||||||
# ===== END: Web Server Setup =====
|
|
||||||
|
|
||||||
# ===== START: Sensor Configuration =====
|
# ===== START: Sensor Configuration =====
|
||||||
# Define all temperature sensors and their alert thresholds
|
# Define all temperature sensors and their alert thresholds
|
||||||
SENSOR_CONFIG = {
|
SENSOR_CONFIG = {
|
||||||
@@ -285,21 +287,36 @@ monitors = [
|
|||||||
print("Starting monitoring loop...")
|
print("Starting monitoring loop...")
|
||||||
print("Press Ctrl+C to stop\n")
|
print("Press Ctrl+C to stop\n")
|
||||||
|
|
||||||
|
# Add NTP retry flags (before main loop)
|
||||||
|
retry_ntp_attempts = 0
|
||||||
|
max_ntp_attempts = 5 # Try up to 5 times after initial failure
|
||||||
|
|
||||||
|
|
||||||
# ===== START: Main Loop =====
|
# ===== START: Main Loop =====
|
||||||
# Main monitoring loop (runs forever until Ctrl+C)
|
# Main monitoring loop (runs forever until Ctrl+C)
|
||||||
while True:
|
while True:
|
||||||
# Run all monitors (each checks if it's time to run via should_run())
|
# Run all monitors (each checks if it's time to run via should_run())
|
||||||
run_monitors(monitors)
|
run_monitors(monitors)
|
||||||
|
|
||||||
# Check for incoming web requests (non-blocking)
|
# Web requests
|
||||||
# Pass schedule_monitor so web interface can reload config when schedules change
|
|
||||||
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor)
|
web_server.check_requests(sensors, ac_monitor, heater_monitor, schedule_monitor)
|
||||||
|
|
||||||
# ===== START: Garbage Collection =====
|
# Retry NTP sync every ~10s if not yet synced
|
||||||
# Free up unused memory to prevent fragmentation
|
if not ntp_synced and retry_ntp_attempts < max_ntp_attempts:
|
||||||
|
# Try once immediately, then whenever (time.time() % 10) < 1 (rough 10s window)
|
||||||
|
try:
|
||||||
|
import ntptime
|
||||||
|
if retry_ntp_attempts == 0 or (time.time() % 10) < 1:
|
||||||
|
ntptime.settime()
|
||||||
|
ntp_synced = True
|
||||||
|
print("NTP sync succeeded on retry #{}".format(retry_ntp_attempts + 1))
|
||||||
|
except Exception as e:
|
||||||
|
# Increment only when an actual attempt was made
|
||||||
|
if retry_ntp_attempts == 0 or (time.time() % 10) < 1:
|
||||||
|
retry_ntp_attempts += 1
|
||||||
|
print("NTP retry {} failed: {}".format(retry_ntp_attempts, e))
|
||||||
|
|
||||||
gc.collect()
|
gc.collect()
|
||||||
# ===== END: Garbage Collection =====
|
wdt.feed() # Reset watchdog timer (prevent auto-reboot)
|
||||||
|
|
||||||
# Small delay to prevent CPU overload (0.1 seconds = 10 loops per second)
|
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
# ===== END: Main Loop =====
|
# ===== END: Main Loop =====
|
||||||
Reference in New Issue
Block a user