import socket import time # type: ignore import json class TempWebServer: """Simple web server for viewing temperatures and adjusting settings.""" def __init__(self, port=80): self.port = port self.socket = None self.sensors = {} self.last_page_render = 0 # Track last successful HTML generation def start(self): """Start the web server (non-blocking).""" try: self.socket = socket.socket() self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.socket.bind(('0.0.0.0', self.port)) self.socket.listen(1) self.socket.setblocking(False) print("Web server started on port {}".format(self.port)) except Exception as e: print("Failed to start web server: {}".format(e)) def check_requests(self, sensors, ac_monitor, heater_monitor, schedule_monitor, config): """Check for incoming requests (call in main loop).""" if not self.socket: return try: conn, addr = self.socket.accept() conn.settimeout(3.0) # Read request headers first (in chunks to avoid truncation) request_bytes = b'' while b'\r\n\r\n' not in request_bytes: chunk = conn.recv(512) if not chunk: break request_bytes += chunk if len(request_bytes) > 4096: # Safety limit break # Parse Content-Length from headers request = request_bytes.decode('utf-8') content_length = 0 if 'Content-Length:' in request: for line in request.split('\r\n'): if line.lower().startswith('content-length:'): content_length = int(line.split(':')[1].strip()) break # If POST request with body, read remaining data if 'POST' in request and content_length > 0: # Check how much body we already have header_end = request.find('\r\n\r\n') + 4 body_so_far = request[header_end:] bytes_read = len(body_so_far.encode('utf-8')) bytes_needed = content_length - bytes_read # ===== DEBUG: Print body reading info ===== print("DEBUG POST: Content-Length = {} bytes".format(content_length)) print("DEBUG POST: Already read = {} bytes".format(bytes_read)) print("DEBUG POST: Still need = {} bytes".format(bytes_needed)) # ===== END DEBUG ===== # Read remaining body in loop (recv() may not return all at once!) if bytes_needed > 0: remaining_parts = [] total_read = 0 # Keep reading until we have all bytes while total_read < bytes_needed: chunk = conn.recv(min(512, bytes_needed - total_read)) if not chunk: print("WARNING: Connection closed before all data received!") break remaining_parts.append(chunk) total_read += len(chunk) print("DEBUG POST: Read {} bytes (total: {}/{})".format( len(chunk), total_read, bytes_needed)) remaining = b''.join(remaining_parts) print("DEBUG POST: Read additional {} bytes (expected {})".format( len(remaining), bytes_needed)) request = request[:header_end] + body_so_far + remaining.decode('utf-8') # ===== DEBUG: Print final body length ===== final_body = request[header_end:] print("DEBUG POST: Final body length = {} bytes (expected {})".format( len(final_body), content_length)) print("DEBUG POST: First 100 chars = {}".format(final_body[:100])) # ===== END DEBUG ===== if 'POST /update' in request: response = self._handle_update(request, sensors, ac_monitor, heater_monitor, schedule_monitor, config) # If error page redirects, handle it if isinstance(response, str) and response.startswith('HTTP/1.1'): print("DEBUG: Sending redirect from /update ({} bytes)".format(len(response))) conn.sendall(response.encode('utf-8')) conn.close() print("DEBUG: Redirect sent, connection closed") return elif 'GET /schedule' in request: response = self._get_schedule_editor_page(sensors, ac_monitor, heater_monitor) response_bytes = response.encode('utf-8') # Send headers conn.send('HTTP/1.1 200 OK\r\n') conn.send('Content-Type: text/html; charset=utf-8\r\n') conn.send('Content-Length: {}\r\n'.format(len(response_bytes))) conn.send('Connection: close\r\n') conn.send('\r\n') # Send body in chunks (MicroPython has small socket buffer) chunk_size = 1024 # Send 1KB at a time for i in range(0, len(response_bytes), chunk_size): chunk = response_bytes[i:i+chunk_size] conn.send(chunk) print("DEBUG: Sent chunk {} ({} bytes)".format(i//chunk_size + 1, len(chunk))) conn.close() print("DEBUG: Schedule editor page sent successfully ({} bytes total)".format(len(response_bytes))) return elif 'POST /schedule' in request: response = self._handle_schedule_update(request, sensors, ac_monitor, heater_monitor, schedule_monitor, config) # Redirects are already complete HTTP responses, send directly if isinstance(response, str) and response.startswith('HTTP/1.1'): print("DEBUG: Sending redirect ({} bytes)".format(len(response))) conn.sendall(response.encode('utf-8')) conn.close() print("DEBUG: Redirect sent, connection closed") 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: response = self._get_status_page(sensors, ac_monitor, heater_monitor, schedule_monitor) if response is None: response = self._get_status_page(sensors, ac_monitor, heater_monitor, schedule_monitor) # ===== START: Send response with proper HTTP headers ===== print("DEBUG: Sending response ({} bytes)".format(len(response))) try: # Check if response already has HTTP headers (like redirects) if response.startswith('HTTP/1.1'): # Response already has headers (redirect or other), send as-is conn.sendall(response.encode('utf-8')) else: # HTML response needs headers added first conn.send(b'HTTP/1.1 200 OK\r\n') conn.send(b'Content-Type: text/html; charset=utf-8\r\n') conn.send('Content-Length: {}\r\n'.format(len(response.encode('utf-8'))).encode('utf-8')) conn.send(b'Connection: close\r\n') conn.send(b'\r\n') # Blank line separates headers from body conn.sendall(response.encode('utf-8')) print("DEBUG: Response sent successfully") except Exception as e: print("ERROR: Failed to send response: {}".format(e)) finally: conn.close() print("DEBUG: Client connection closed") # ===== END: Send response ===== except OSError: pass except Exception as e: print("Web server error: {}".format(e)) import sys sys.print_exception(e) def _save_config_to_file(self, config): """Save configuration to config.json file (atomic write).""" try: import os print("DEBUG: Saving config with {} schedules".format(len(config.get('schedules', [])))) # Write to temp file first with open('config.tmp', 'w') as 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") return True except Exception as e: print("❌ Error saving config: {}".format(e)) import sys sys.print_exception(e) return False def _load_config(self): """Load configuration from file.""" try: with open('config.json', 'r') as f: return json.load(f) except: return { 'ac_target': 77.0, 'ac_swing': 1.0, 'heater_target': 80.0, 'heater_swing': 2.0, 'schedules': [], 'schedule_enabled': False, 'permanent_hold': False } def _handle_schedule_update(self, request, sensors, ac_monitor, heater_monitor, schedule_monitor, config): """Handle schedule form submission.""" try: body = request.split('\r\n\r\n')[1] if '\r\n\r\n' in request else '' params = {} for pair in body.split('&'): if '=' in pair: key, value = pair.split('=', 1) params[key] = value.replace('+', ' ') # ===== START: Handle mode actions ===== mode_action = params.get('mode_action', '') if mode_action == 'resume': # Resume automatic scheduling config['schedule_enabled'] = True config['permanent_hold'] = False if self._save_config_to_file(config): print("▶️ Schedule resumed - Automatic mode") if schedule_monitor: schedule_monitor.reload_config(config) try: from scripts.discord_webhook import send_discord_message send_discord_message("▶️ Schedule resumed - Automatic temperature control active") except: pass # Redirect back to Dashboard with proper headers redirect_response = 'HTTP/1.1 303 See Other\r\n' redirect_response += 'Location: /\r\n' redirect_response += 'Content-Length: 0\r\n' redirect_response += 'Connection: close\r\n' redirect_response += '\r\n' print("DEBUG: Returning redirect to dashboard") return redirect_response elif mode_action == 'temporary_hold': # Enter temporary hold (pause schedules temporarily) config['schedule_enabled'] = False config['permanent_hold'] = False if self._save_config_to_file(config): print("⏸️ Temporary hold activated") if schedule_monitor: schedule_monitor.reload_config(config) try: from scripts.discord_webhook import send_discord_message send_discord_message("⏸️ Temporary hold - Schedules paused, manual control active") except: pass # Redirect to dashboard after error (settings weren't saved) redirect_response = 'HTTP/1.1 303 See Other\r\n' redirect_response += 'Location: /\r\n' redirect_response += 'Content-Length: 0\r\n' redirect_response += 'Connection: close\r\n' redirect_response += '\r\n' return redirect_response elif mode_action == 'permanent_hold': # Enter permanent hold (disable schedules permanently) config['schedule_enabled'] = False config['permanent_hold'] = True if self._save_config_to_file(config): print("🛑 Permanent hold activated") if schedule_monitor: schedule_monitor.reload_config(config) try: from scripts.discord_webhook import send_discord_message send_discord_message("🛑 Permanent hold - Schedules disabled, manual control only") except: pass # Redirect back to Dashboard with proper headers redirect_response = 'HTTP/1.1 303 See Other\r\n' redirect_response += 'Location: /\r\n' redirect_response += 'Content-Length: 0\r\n' redirect_response += 'Connection: close\r\n' redirect_response += '\r\n' print("DEBUG: Returning redirect to dashboard") return redirect_response elif mode_action == 'save_schedules': # Just fall through to schedule parsing below pass # ===== END: Handle mode actions ===== # ===== START: Handle schedule configuration save ===== # DEBUG: Print what we received print("DEBUG: Received POST body parameters:") for key, value in params.items(): print(" {} = '{}'".format(key, value)) print("DEBUG: Total params received: {}".format(len(params))) # Parse schedules (4 slots) schedules = [] has_any_schedule_data = False for i in range(4): time_key = 'schedule_{}_time'.format(i) name_key = 'schedule_{}_name'.format(i) ac_key = 'schedule_{}_ac'.format(i) heater_key = 'schedule_{}_heater'.format(i) # Check if this schedule slot has data if time_key in params or name_key in params or ac_key in params or heater_key in params: has_any_schedule_data = True if time_key in params and params[time_key]: # ===== VALIDATE: If time is set, AC and Heater MUST be set ===== if ac_key not in params or not params[ac_key]: print("❌ Validation failed: Schedule {} has time but missing AC target".format(i+1)) return self._get_error_page( "Incomplete Schedule", "Schedule {}: AC target is required when time is set".format(i+1), sensors, ac_monitor, heater_monitor ) if heater_key not in params or not params[heater_key]: print("❌ Validation failed: Schedule {} has time but missing Heater target".format(i+1)) return self._get_error_page( "Incomplete Schedule", "Schedule {}: Heater target is required when time is set".format(i+1), sensors, ac_monitor, heater_monitor ) # ===== END VALIDATION ===== # URL decode the time (converts %3A back to :) schedule_time = params[time_key].replace('%3A', ':') # Validate time format if ':' not in schedule_time or len(schedule_time.split(':')) != 2: print("Invalid time format: {}".format(schedule_time)) return self._get_error_page( "Invalid Time", "Schedule {}: Time format must be HH:MM".format(i+1), sensors, ac_monitor, heater_monitor ) try: hours, mins = schedule_time.split(':') if not (0 <= int(hours) <= 23 and 0 <= int(mins) <= 59): raise ValueError except: print("Invalid time value: {}".format(schedule_time)) return self._get_error_page( "Invalid Time", "Schedule {}: Invalid time value {}".format(i+1, schedule_time), sensors, ac_monitor, heater_monitor ) # URL decode the name schedule_name = params.get(name_key, 'Schedule {}'.format(i+1)).replace('+', ' ') # Parse temperatures (they're guaranteed to exist due to validation above) try: ac_target = float(params[ac_key]) heater_target = float(params[heater_key]) except (ValueError, TypeError): return self._get_error_page( "Invalid Temperature", "Schedule {}: Temperature values must be numbers".format(i+1), sensors, ac_monitor, heater_monitor ) # Create schedule entry schedule = { 'time': schedule_time, 'name': schedule_name, 'ac_target': ac_target, 'heater_target': heater_target } schedules.append(schedule) print("DEBUG: Parsed schedule {}: time='{}', name='{}', heater={}, ac={}".format( i, schedule_time, schedule_name, heater_target, ac_target)) # Only update schedules if user submitted schedule form data if has_any_schedule_data: config['schedules'] = schedules print("Updating schedules: {} schedules configured".format(len(schedules))) else: # No schedule data in form - preserve existing schedules print("No schedule data in request - preserving existing schedules") # ===== START: Validate all schedules ===== for i, schedule in enumerate(schedules): heater_temp = schedule.get('heater_target', 80.0) ac_temp = schedule.get('ac_target', 77.0) if heater_temp > ac_temp: print("❌ Schedule validation failed: Schedule {} has heater ({}) > AC ({})".format( i+1, heater_temp, ac_temp )) return self._get_error_page( "Invalid Schedule", "Schedule {} ({}): Heater target ({:.1f}°F) cannot be greater than AC target ({:.1f}°F)".format( i+1, schedule.get('name', 'Unnamed'), heater_temp, ac_temp ), sensors, ac_monitor, heater_monitor ) # ===== END: Validate all schedules ===== # Save to file if self._save_config_to_file(config): print("Schedule configuration saved") # ===== ADD THIS: Reload config into memory immediately ===== try: with open('config.json', 'r') as f: updated_config = json.load(f) # Update the passed-in config dict (updates reference, not copy) config.clear() config.update(updated_config) print("✅ Config reloaded into memory") except Exception as e: print("⚠️ Warning: Could not reload config: {}".format(e)) # ===== END: Reload config ===== if schedule_monitor: schedule_monitor.reload_config(config) # Update AC and heater monitors with new targets from config if ac_monitor: ac_monitor.target_temp = config['ac_target'] ac_monitor.temp_swing = config['ac_swing'] if heater_monitor: heater_monitor.target_temp = config['heater_target'] heater_monitor.temp_swing = config['heater_swing'] # Send Discord notification try: from scripts.discord_webhook import send_discord_message mode = "automatic" if config.get('schedule_enabled') else "hold" message = "📅 Schedules updated ({} mode) - {} schedules configured".format( mode, len(schedules) ) send_discord_message(message) except: pass # ===== END: Handle schedule configuration save ===== # Redirect back to homepage with cache-busting headers redirect_response = 'HTTP/1.1 303 See Other\r\n' redirect_response += 'Location: /\r\n' redirect_response += 'Content-Length: 0\r\n' redirect_response += 'Connection: close\r\n' redirect_response += 'Cache-Control: no-cache, no-store, must-revalidate\r\n' redirect_response += 'Pragma: no-cache\r\n' redirect_response += 'Expires: 0\r\n' redirect_response += '\r\n' print("DEBUG: Returning redirect to dashboard (with cache-busting)") return redirect_response except Exception as e: print("Error updating schedule: {}".format(e)) import sys sys.print_exception(e) # Safety: avoid rendering an error page here; just redirect redirect_response = 'HTTP/1.1 303 See Other\r\n' redirect_response += 'Location: /schedule\r\n' redirect_response += 'Content-Length: 0\r\n' redirect_response += 'Connection: close\r\n' redirect_response += '\r\n' return redirect_response def _handle_update(self, request, sensors, ac_monitor, heater_monitor, schedule_monitor, config): """Handle form submission and update settings.""" try: body = request.split('\r\n\r\n')[1] if '\r\n\r\n' in request else '' params = {} for pair in body.split('&'): if '=' in pair: key, value = pair.split('=', 1) params[key] = float(value) # ===== START: Validate Heat <= AC ===== # Get the values that will be set new_heater_target = params.get('heater_target', config.get('heater_target', 80.0)) new_ac_target = params.get('ac_target', config.get('ac_target', 77.0)) # Validation: Heater must be <= AC if new_heater_target > new_ac_target: print("❌ Validation failed: Heater target ({}) cannot be greater than AC target ({})".format( new_heater_target, new_ac_target )) # Return error page return self._get_error_page( "Invalid Settings", "Heater target ({:.1f}°F) cannot be greater than AC target ({:.1f}°F)".format( new_heater_target, new_ac_target ), sensors, ac_monitor, heater_monitor ) # ===== END: Validate Heat <= AC ===== # ===== START: Update AC Settings ===== if 'ac_target' in params and ac_monitor: ac_monitor.target_temp = params['ac_target'] # Update monitor config['ac_target'] = params['ac_target'] # Update config print("AC target updated to {}°F".format(params['ac_target'])) if 'ac_swing' in params and ac_monitor: ac_monitor.temp_swing = params['ac_swing'] # Update monitor config['ac_swing'] = params['ac_swing'] # Update config print("AC swing updated to {}°F".format(params['ac_swing'])) # ===== END: Update AC Settings ===== # ===== START: Update Heater Settings ===== if 'heater_target' in params and heater_monitor: heater_monitor.target_temp = params['heater_target'] # Update monitor config['heater_target'] = params['heater_target'] # Update config print("Heater target updated to {}°F".format(params['heater_target'])) if 'heater_swing' in params and heater_monitor: heater_monitor.temp_swing = params['heater_swing'] # Update monitor config['heater_swing'] = params['heater_swing'] # Update config print("Heater swing updated to {}°F".format(params['heater_swing'])) # ===== END: Update Heater Settings ===== # ===== START: ALWAYS enter temporary hold when Save Settings is clicked ===== # User clicked "Save Settings" - enter temporary hold mode config['schedule_enabled'] = False config['permanent_hold'] = False config['temp_hold_start_time'] = time.time() # SET START TIME print("⏸️ Temporary hold activated - Manual override") # Reload schedule monitor to disable it if schedule_monitor: schedule_monitor.reload_config(config) # ===== END: ALWAYS enter temporary hold ===== # ===== START: Save settings to file ===== if self._save_config_to_file(config): print("Settings persisted to disk") # ===== RELOAD config into memory immediately ===== try: with open('config.json', 'r') as f: updated_config = json.load(f) # Update the passed-in config dict (updates reference, not copy) config.clear() config.update(updated_config) print("✅ Config reloaded into memory") except Exception as e: print("⚠️ Warning: Could not reload config: {}".format(e)) # ===== END: Reload config ===== # ===== END: Save settings to file ===== # ===== START: Send Discord notification ===== try: from scripts.discord_webhook import send_discord_message ac_target_str = str(params.get('ac_target', 'N/A')) ac_swing_str = str(params.get('ac_swing', 'N/A')) heater_target_str = str(params.get('heater_target', 'N/A')) heater_swing_str = str(params.get('heater_swing', 'N/A')) message = "⏸️ TEMPORARY HOLD - AC: {}F ± {}F | Heater: {}F ± {}F (1 hour)".format( ac_target_str, ac_swing_str, heater_target_str, heater_swing_str ) send_discord_message(message) except Exception as discord_error: print("Discord notification failed: {}".format(discord_error)) # ===== END: Send Discord notification ===== # ===== START: Debug output ===== print("DEBUG: After update, monitor values are:") if ac_monitor: print(" AC target: {}".format(ac_monitor.target_temp)) print(" AC swing: {}".format(ac_monitor.temp_swing)) if heater_monitor: print(" Heater target: {}".format(heater_monitor.target_temp)) print(" Heater swing: {}".format(heater_monitor.temp_swing)) # ===== END: Debug output ===== except Exception as e: print("Error updating settings: {}".format(e)) import sys sys.print_exception(e) return self._get_status_page(sensors, ac_monitor, heater_monitor, schedule_monitor, show_success=True) def _get_status_page(self, sensors, ac_monitor, heater_monitor, schedule_monitor=None, show_success=False): """Generate HTML status page.""" print("DEBUG: Generating status page...") try: # Get current temperatures (use cached values to avoid blocking) inside_temp = getattr(sensors.get('inside'), 'last_temp', None) outside_temp = getattr(sensors.get('outside'), 'last_temp', None) # Fallback to sensor read if no cached value (first load only) 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 ac_status = "ON" if ac_monitor and ac_monitor.ac.get_state() else "OFF" heater_status = "ON" if heater_monitor and heater_monitor.heater.get_state() else "OFF" # Get current time current_time = time.localtime() time_str = "{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format( current_time[0], current_time[1], current_time[2], current_time[3], current_time[4], current_time[5] ) # Load config config = self._load_config() # ===== START: Determine schedule status display ===== has_schedules = len([s for s in config.get('schedules', []) if s.get('time')]) > 0 if not has_schedules: schedule_status = "NO SCHEDULES" schedule_color = "#95a5a6" schedule_icon = "⚠️" elif config.get('schedule_enabled'): schedule_status = "AUTOMATIC" schedule_color = "#2ecc71" schedule_icon = "✅" elif config.get('permanent_hold', False): schedule_status = "PERMANENT HOLD" schedule_color = "#e74c3c" schedule_icon = "🛑" else: schedule_status = "TEMPORARY HOLD" schedule_color = "#f39c12" schedule_icon = "⏸️" # ===== END: Determine schedule status display ===== # Build schedule cards schedule_cards = "" # Build mode buttons for dashboard mode_buttons = self._build_mode_buttons(config) if config.get('schedules'): for schedule in config.get('schedules', []): # ===== START: Decode URL-encoded values ===== # Replace %3A with : and + with space time_value = schedule.get('time', 'N/A').replace('%3A', ':') name_value = schedule.get('name', 'Unnamed').replace('+', ' ') # ===== END: Decode URL-encoded values ===== schedule_cards += """
{}".format(str(e))
def _get_error_page(self, error_title, error_message, sensors, ac_monitor, heater_monitor):
"""Generate error page with message."""
# Get current temps (cached, fast - no blocking sensor reads)
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)
# Get current statuses
ac_status = "ON" if ac_monitor and ac_monitor.ac.get_state() else "OFF"
heater_status = "ON" if heater_monitor and heater_monitor.heater.get_state() else "OFF"
html = """