diff --git a/Scripts/discord_webhook.py b/Scripts/discord_webhook.py index 99525e8..f19ee36 100644 --- a/Scripts/discord_webhook.py +++ b/Scripts/discord_webhook.py @@ -1,8 +1,23 @@ -import urequests as requests # type: ignore -from secrets import secrets +# Minimal module-level state (only what we need) +_CONFIG = {"discord_webhook_url": None, "discord_alert_webhook_url": None} + +def set_config(cfg: dict): + """Initialize module with minimal values from loaded config (call from main).""" + global _CONFIG + if not cfg: + _CONFIG = {"discord_webhook_url": None, "discord_alert_webhook_url": None} + return + _CONFIG = { + "discord_webhook_url": cfg.get("discord_webhook_url"), + "discord_alert_webhook_url": cfg.get("discord_alert_webhook_url"), + } + +def _get_webhook_url(is_alert: bool = False): + if is_alert: + return _CONFIG.get("discord_alert_webhook_url") or _CONFIG.get("discord_webhook_url") + return _CONFIG.get("discord_webhook_url") def _escape_json_str(s: str) -> str: - # minimal JSON string escaper for quotes/backslashes and control chars s = s.replace("\\", "\\\\") s = s.replace('"', '\\"') s = s.replace("\n", "\\n") @@ -11,44 +26,52 @@ def _escape_json_str(s: str) -> str: return s def send_discord_message(message, username="Auto Garden Bot", is_alert=False): - """Send Discord message with 3-second timeout to prevent blocking.""" + """ + Send Discord message. Import urequests locally to avoid occupying RAM when idle. + Returns True on success, False otherwise. + """ resp = None - - # Use alert webhook if specified, otherwise normal webhook - if is_alert: - url = secrets.get('discord_alert_webhook_url') or secrets.get('discord_webhook_url') - else: - url = secrets.get('discord_webhook_url') - + url = _get_webhook_url(is_alert=is_alert) + if not url: + return False + try: - if not url: - return False + # local import to save RAM + import urequests as requests # type: ignore + import gc - url = url.strip().strip('\'"') - - # Build JSON manually to preserve emoji/unicode as UTF-8 + url = str(url).strip().strip('\'"') content = _escape_json_str(message) user = _escape_json_str(username) body_bytes = ('{"content":"%s","username":"%s"}' % (content, user)).encode("utf-8") - headers = {"Content-Type": "application/json; charset=utf-8"} - # Make POST request (urequests has built-in ~5s timeout) resp = requests.post(url, data=body_bytes, headers=headers) status = getattr(resp, "status", getattr(resp, "status_code", None)) - - if status and 200 <= status < 300: - return True - else: - return False + success = bool(status and 200 <= status < 300) + if not success: + # optional: print status for debugging, but avoid spamming + print("Discord webhook failed, status:", status) + return success except Exception as e: - # Silently fail (don't spam console with Discord errors) + # avoid raising to prevent crashing monitors; print minimal info + print("Discord webhook exception:", e) return False + finally: - if resp: - try: + try: + if resp: resp.close() - except: - pass \ No newline at end of file + except: + pass + # free large objects and modules, then force GC + try: + del resp + except: + pass + try: + gc.collect() + except: + pass \ No newline at end of file diff --git a/Scripts/monitors.py b/Scripts/monitors.py index 8b8effe..029c2a1 100644 --- a/Scripts/monitors.py +++ b/Scripts/monitors.py @@ -1,5 +1,5 @@ import time # type: ignore -from scripts.discord_webhook import send_discord_message +import scripts.discord_webhook as discord_webhook from scripts.temperature_sensor import TemperatureSensor class Monitor: @@ -108,13 +108,11 @@ class TemperatureMonitor(Monitor): self.alert_start_time = current_time print(alert_message) - # Send to appropriate Discord channel + # send alert (use module-level discord_webhook; set_config must be called in main) if self.send_alerts_to_separate_channel: - from scripts.discord_webhook import send_discord_message - send_discord_message(alert_message, is_alert=True) + discord_webhook.send_discord_message(alert_message, is_alert=True) else: - from scripts.discord_webhook import send_discord_message - send_discord_message(alert_message) + discord_webhook.send_discord_message(alert_message) self.alert_sent = True @@ -139,13 +137,11 @@ class TemperatureMonitor(Monitor): ) print(recovery_message) - # Send to appropriate Discord channel + # send recovery message if self.send_alerts_to_separate_channel: - from scripts.discord_webhook import send_alert_message - send_alert_message(recovery_message) + discord_webhook.send_discord_message(recovery_message, is_alert=True) else: - from scripts.discord_webhook import send_discord_message - send_discord_message(recovery_message) + discord_webhook.send_discord_message(recovery_message) self.alert_sent = False self.alert_start_time = None @@ -198,14 +194,14 @@ class ACMonitor(Monitor): # Too hot, turn AC on if self.ac.turn_on(): if not self.last_notified_state: - send_discord_message(f"❄️ AC turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F") + discord_webhook.send_discord_message(f"❄️ AC turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F") self.last_notified_state = True elif current_temp < (self.target_temp - self.temp_swing): # Cool enough, turn AC off if self.ac.turn_off(): if self.last_notified_state: - send_discord_message(f"✅ AC turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F") + discord_webhook.send_discord_message(f"✅ AC turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F") self.last_notified_state = False # Else: within temp_swing range, maintain current state @@ -244,27 +240,28 @@ class HeaterMonitor(Monitor): # Too cold, turn heater on if self.heater.turn_on(): if not self.last_notified_state: - send_discord_message(f"🔥 Heater turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F") + discord_webhook.send_discord_message(f"🔥 Heater turned ON - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F") self.last_notified_state = True elif current_temp > (self.target_temp + self.temp_swing): # Warm enough, turn heater off if self.heater.turn_off(): if self.last_notified_state: - send_discord_message(f"✅ Heater turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F") + discord_webhook.send_discord_message(f"✅ Heater turned OFF - Current: {current_temp:.1f}°F, Target: {self.target_temp:.1f}°F") self.last_notified_state = False # Else: within temp_swing range, maintain current state class WiFiMonitor(Monitor): """Monitor WiFi connection and handle reconnection.""" - def __init__(self, wifi, led, interval=5, reconnect_cooldown=60): + def __init__(self, wifi, led, interval=5, reconnect_cooldown=60, config=None): super().__init__(interval) self.wifi = wifi self.led = led self.reconnect_cooldown = reconnect_cooldown self.last_reconnect_attempt = 0 self.was_connected = wifi.isconnected() if wifi else False + self.config = config def run(self): """Check WiFi status, blink LED, attempt reconnect if needed.""" @@ -284,10 +281,10 @@ class WiFiMonitor(Monitor): if time.ticks_diff(now, self.last_reconnect_attempt) >= (self.reconnect_cooldown * 1000): self.last_reconnect_attempt = now # print("Attempting WiFi reconnect...") - self.wifi = connect_wifi(self.led) + self.wifi = connect_wifi(self.led, config=self.config) if self.wifi and self.wifi.isconnected(): - send_discord_message("WiFi connection restored 🔄") + discord_webhook.send_discord_message("WiFi connection restored 🔄") self.was_connected = True else: # Slow blink when connected @@ -297,7 +294,7 @@ class WiFiMonitor(Monitor): # Notify if connection was just restored if not self.was_connected: - send_discord_message("WiFi connection restored 🔄") + discord_webhook.send_discord_message("WiFi connection restored 🔄") self.was_connected = True def run_monitors(monitors): diff --git a/Scripts/networking.py b/Scripts/networking.py index e4a5fba..affef67 100644 --- a/Scripts/networking.py +++ b/Scripts/networking.py @@ -1,30 +1,43 @@ import network import time -from secrets import secrets -def connect_wifi(led=None, max_retries=3, timeout=20): +def connect_wifi(led=None, max_retries=3, timeout=20, config=None): """ - Connect to WiFi using credentials from secrets.py - + Connect to WiFi using credentials from provided config dict. + Args: led: Optional LED pin for visual feedback max_retries: Number of connection attempts (default: 3) timeout: Seconds to wait for connection per attempt (default: 20) - + config: Dict loaded from config.json, must contain config['wifi'] with 'ssid' and 'password' + Returns: WLAN object if connected, None if failed """ + if config is None: + print("connect_wifi: config is required") + return None + + wifi_cfg = config.get('wifi') or {} +# support either config['wifi'] = {'ssid','password'} OR top-level 'ssid'/'password' + ssid = wifi_cfg.get('ssid') or config.get('ssid') + password = wifi_cfg.get('password') or config.get('password') + + if not ssid or not password: + print("connect_wifi: missing wifi credentials in config['wifi']") + return None + wlan = network.WLAN(network.STA_IF) - + # Ensure clean state try: if wlan.active(): wlan.active(False) time.sleep(1) - + wlan.active(True) time.sleep(1) - + except OSError as e: print(f"WiFi activation error: {e}") print("Attempting reset...") @@ -37,66 +50,83 @@ def connect_wifi(led=None, max_retries=3, timeout=20): except Exception as e2: print(f"WiFi reset failed: {e2}") return None - + # Try connecting with retries for attempt in range(1, max_retries + 1): if wlan.isconnected(): - print(f"Already connected to WiFi") + print("Already connected to WiFi") break - - print(f'Connecting to WiFi (attempt {attempt}/{max_retries})...') - + + print(f'Connecting to WiFi SSID: {ssid} (attempt {attempt}/{max_retries})...') + try: - wlan.connect(secrets['ssid'], secrets['password']) + wlan.connect(ssid, password) except Exception as e: print(f"Connection attempt failed: {e}") if attempt < max_retries: print("Retrying in 3 seconds...") time.sleep(3) continue - + # Wait for connection with timeout wait_time = 0 while wait_time < timeout: if wlan.isconnected(): break - + if led: - led.toggle() - + try: + # some LED wrappers use toggle(), others use on/off + if hasattr(led, "toggle"): + led.toggle() + else: + # flash quickly to show activity + led.on() + time.sleep(0.05) + led.off() + except Exception: + pass + time.sleep(0.5) wait_time += 0.5 - + # Print progress dots every 2 seconds if int(wait_time * 2) % 4 == 0: print('.', end='') - + print() # New line after dots - + if wlan.isconnected(): break - + print(f'Connection attempt {attempt} failed') if attempt < max_retries: print("Retrying in 3 seconds...") time.sleep(3) - + # Final connection check if not wlan.isconnected(): print('WiFi connection failed after all attempts!') if led: - led.off() + try: + # prefer available method names + if hasattr(led, "off"): + led.off() + except Exception: + pass return None - + # Success feedback if led: - # Double pulse on successful connection - for _ in range(2): - led.on() - time.sleep(0.2) - led.off() - time.sleep(0.2) - + try: + for _ in range(2): + led.on() + time.sleep(0.2) + led.off() + time.sleep(0.2) + except Exception: + pass + print('Connected to WiFi successfully!') return wlan \ No newline at end of file diff --git a/Scripts/scheduler.py b/Scripts/scheduler.py index 9f1d15b..46d4f77 100644 --- a/Scripts/scheduler.py +++ b/Scripts/scheduler.py @@ -90,32 +90,61 @@ class ScheduleMonitor: return # Already applied try: + # Track whether we changed persisted values to avoid unnecessary writes + changed = False + # Update AC settings if provided if 'ac_target' in schedule: - self.ac_monitor.target_temp = float(schedule['ac_target']) - self.config['ac_target'] = float(schedule['ac_target']) + new_ac = float(schedule['ac_target']) + if self.config.get('ac_target') != new_ac: + self.config['ac_target'] = new_ac + changed = True + self.ac_monitor.target_temp = new_ac if 'ac_swing' in schedule: - self.ac_monitor.temp_swing = float(schedule['ac_swing']) - self.config['ac_swing'] = float(schedule['ac_swing']) + new_ac_swing = float(schedule['ac_swing']) + if self.config.get('ac_swing') != new_ac_swing: + self.config['ac_swing'] = new_ac_swing + changed = True + self.ac_monitor.temp_swing = new_ac_swing # Update heater settings if provided if 'heater_target' in schedule: - self.heater_monitor.target_temp = float(schedule['heater_target']) - self.config['heater_target'] = float(schedule['heater_target']) + new_ht = float(schedule['heater_target']) + if self.config.get('heater_target') != new_ht: + self.config['heater_target'] = new_ht + changed = True + self.heater_monitor.target_temp = new_ht if 'heater_swing' in schedule: - self.heater_monitor.temp_swing = float(schedule['heater_swing']) - self.config['heater_swing'] = float(schedule['heater_swing']) + new_ht_swing = float(schedule['heater_swing']) + if self.config.get('heater_swing') != new_ht_swing: + self.config['heater_swing'] = new_ht_swing + changed = True + self.heater_monitor.temp_swing = new_ht_swing - # Save updated config to file so targets persist - try: - import json - with open('config.json', 'w') as f: - json.dump(self.config, f) - print("✅ Config updated with active schedule targets") - except Exception as e: - print("⚠️ Could not save config: {}".format(e)) + # Save updated config only if something changed + if changed: + try: + import json + with open('config.json', 'w') as f: + json.dump(self.config, f) + except Exception as e: + print("⚠️ Could not save config: {}".format(e)) + else: + # import once and update module-level webhook config + try: + import scripts.discord_webhook as discord_webhook + discord_webhook.set_config(self.config) + except Exception: + pass + print("✅ Config updated with active schedule targets") + else: + # nothing to persist + try: + import scripts.discord_webhook as discord_webhook + except Exception: + discord_webhook = None # Log the change schedule_name = schedule.get('name', 'Unnamed') @@ -125,21 +154,22 @@ class ScheduleMonitor: print("AC Target: {}°F".format(self.ac_monitor.target_temp)) print("Heater Target: {}°F".format(self.heater_monitor.target_temp)) print("="*50 + "\n") - - # Send Discord notification + + # Send Discord notification (use discord_webhook if available) try: - from scripts.discord_webhook import send_discord_message + if 'discord_webhook' not in locals() or discord_webhook is None: + import scripts.discord_webhook as discord_webhook message = "🕐 Schedule '{}' applied - AC: {}°F | Heater: {}°F".format( schedule_name, self.ac_monitor.target_temp, self.heater_monitor.target_temp ) - send_discord_message(message) - except: + discord_webhook.send_discord_message(message) + except Exception: pass - + self.last_applied_schedule = schedule_id - + except Exception as e: print("Error applying schedule: {}".format(e)) @@ -165,16 +195,24 @@ class ScheduleMonitor: import json with open('config.json', 'w') as f: json.dump(self.config, f) - print("✅ Config updated - automatic mode resumed") except Exception as e: print("⚠️ Could not save config: {}".format(e)) - - # Notify user - try: - from scripts.discord_webhook import send_discord_message - send_discord_message("⏰ Temporary hold expired - Schedule resumed automatically") - except: - pass + else: + # ensure in-memory webhook config updated + try: + import scripts.discord_webhook as discord_webhook + discord_webhook.set_config(self.config) + except Exception: + pass + + print("✅ Config updated - automatic mode resumed") + + # Notify user + try: + import scripts.discord_webhook as discord_webhook + discord_webhook.send_discord_message("⏰ Temporary hold expired - Schedule resumed automatically") + except Exception: + pass # ===== END: Check if temporary hold has expired ===== # Find and apply active schedule diff --git a/Scripts/web_server.py b/Scripts/web_server.py index ba01fc3..1a1e0be 100644 --- a/Scripts/web_server.py +++ b/Scripts/web_server.py @@ -1,6 +1,9 @@ import socket import time # type: ignore import json +import scripts.discord_webhook as discord_webhook +import os + class TempWebServer: """Simple web server for viewing temperatures and adjusting settings.""" @@ -106,8 +109,8 @@ class TempWebServer: 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.sendall(b'HTTP/1.1 200 OK\r\n') + conn.sendall(b'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') @@ -224,6 +227,12 @@ class TempWebServer: # Rename temp to config (atomic on most filesystems) os.rename('config.tmp', 'config.json') + # Update discord module in-memory config so webhook URLs are current + try: + discord_webhook.set_config(config) + except Exception: + pass + print("Settings saved to config.json") return True except Exception as e: @@ -276,8 +285,7 @@ class TempWebServer: # Send Discord notification try: - from scripts.discord_webhook import send_discord_message - send_discord_message("▶️ Schedule resumed - Automatic temperature control active") + discord_webhook.send_discord_message("▶️ Schedule resumed - Automatic temperature control active") except: pass @@ -302,8 +310,7 @@ class TempWebServer: schedule_monitor.reload_config(config) try: - from scripts.discord_webhook import send_discord_message - send_discord_message("⏸️ Temporary hold - Schedules paused, manual control active") + discord_webhook.send_discord_message("⏸️ Temporary hold - Schedules paused, manual control active") except: pass @@ -327,8 +334,7 @@ class TempWebServer: schedule_monitor.reload_config(config) try: - from scripts.discord_webhook import send_discord_message - send_discord_message("🛑 Permanent hold - Schedules disabled, manual control only") + discord_webhook.send_discord_message("🛑 Permanent hold - Schedules disabled, manual control only") except: pass @@ -490,12 +496,11 @@ class TempWebServer: # 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) + discord_webhook.send_discord_message(message) except: pass # ===== END: Handle schedule configuration save ===== @@ -611,7 +616,6 @@ class TempWebServer: # ===== START: Send Discord notification ===== try: - from scripts.discord_webhook import send_discord_message hold_label = "PERMANENT HOLD" if is_permanent else "TEMPORARY HOLD" duration = "" if is_permanent else " (1 hour)" @@ -622,7 +626,7 @@ class TempWebServer: params.get('heater_target', 'N/A'), duration ) - send_discord_message(message) + discord_webhook.send_discord_message(message) except Exception as discord_error: print("Discord notification failed: {}".format(discord_error)) # ===== END: Send Discord notification ===== @@ -1754,8 +1758,7 @@ class TempWebServer: # Discord notification try: - from scripts.discord_webhook import send_discord_message - send_discord_message("⚙️ Advanced settings updated") + discord_webhook.send_discord_message("⚙️ Advanced settings updated") except: pass diff --git a/config.json.Example b/config.json.Example new file mode 100644 index 0000000..1085938 --- /dev/null +++ b/config.json.Example @@ -0,0 +1,45 @@ +{ + "static_ip": "192.168.1.69", + "subnet": "255.255.255.0", + "gateway": "192.168.1.1", + "dns": "192.168.1.1", + "timezone_offset": -5, + "ssid": " Change_to_wifi_SSID", + "password": "Change_to_wifi_Pasword", + "discord_webhook_url": "https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe", + "discord_alert_webhook_url": "https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe", + "ac_target": 77.0, + "ac_swing": 1.0, + "heater_target": 72.0, + "heater_swing": 2.0, + "temp_hold_duration": 3600, + "schedules": [ + { + "time": "06:00", + "ac_target": 75.0, + "heater_target": 72.0, + "name": "Morning" + }, + { + "time": "12:00", + "ac_target": 75.0, + "heater_target": 72.0, + "name": "Midday" + }, + { + "time": "18:00", + "ac_target": 75.0, + "heater_target": 72.0, + "name": "Evening" + }, + { + "time": "22:00", + "ac_target": 75.0, + "heater_target": 72.0, + "name": "Night" + } + ], + "schedule_enabled": true, + "permanent_hold": false, + "temp_hold_start_time": null +} \ No newline at end of file diff --git a/main.py b/main.py index c2b9927..8bfb5ec 100644 --- a/main.py +++ b/main.py @@ -21,7 +21,7 @@ except Exception as e: # Import after WiFi reset from scripts.networking import connect_wifi -from scripts.discord_webhook import send_discord_message +import scripts.discord_webhook as discord_webhook from scripts.monitors import TemperatureMonitor, WiFiMonitor, ACMonitor, HeaterMonitor, run_monitors from scripts.temperature_sensor import TemperatureSensor from scripts.air_conditioning import ACController @@ -146,6 +146,8 @@ def load_config(): # Load configuration from file config = load_config() +# Initialize discord webhook module with loaded config (must be done BEFORE any send_discord_message calls) +discord_webhook.set_config(config) # Get timezone offset from config (with fallback to -6 if not present) TIMEZONE_OFFSET = config.get('timezone_offset', -6) @@ -171,7 +173,7 @@ except Exception as e: # ===== START: WiFi Connection ===== # Connect to WiFi using credentials from secrets.py -wifi = connect_wifi(led) +wifi = connect_wifi(led, config=config) # Set static IP and print WiFi details if wifi and wifi.isconnected(): @@ -199,7 +201,7 @@ if wifi and wifi.isconnected(): # Send startup notification to Discord (with timeout, non-blocking) try: - success = send_discord_message(f"Pico W online at http://{ifconfig[0]} ✅") + success = discord_webhook.send_discord_message(f"Pico W online at http://{ifconfig[0]} ✅") if success: print("Discord startup notification sent") else: @@ -345,7 +347,7 @@ check_memory_once() # Set up all monitoring systems (run in order during main loop) monitors = [ # WiFi monitor: Checks connection, reconnects if needed, blinks LED - WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60), + WiFiMonitor(wifi, led, interval=5, reconnect_cooldown=60, config=config), # Schedule monitor: Changes temp targets based on time of day schedule_monitor, diff --git a/secrets.example.py b/secrets.example.py deleted file mode 100644 index 0ca51d2..0000000 --- a/secrets.example.py +++ /dev/null @@ -1,6 +0,0 @@ -secrets = { - 'ssid': ' Change_to_wifi_SSID', - 'password': 'Change_to_wifi_Pasword', - 'discord_webhook_url': 'https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe', # normal updates - 'discord_alert_webhook_url': 'https://discord.com/api/webhooks/key/long-Combination_1234-ChangeMe', # alerts only - } \ No newline at end of file